diff --git a/DscResources/MSFT_GroupResource/MSFT_GroupResource.psm1 b/DscResources/MSFT_GroupResource/MSFT_GroupResource.psm1 new file mode 100644 index 0000000..2258899 --- /dev/null +++ b/DscResources/MSFT_GroupResource/MSFT_GroupResource.psm1 @@ -0,0 +1,2515 @@ +<# + Implementatation Notes + + Managing Disposable Objects + The types PrincipalContext, Principal, and DirectoryEntry are used througout the code and + all are disposable. However, in many cases, disposing the object immediately causes + subsequent operations to fail or duplicate disposes calls to occur. + + To simplify management of these disposables, each public entry point defines a $disposables + ArrayList variable and passes it to secondary functions that may need to create disposable + objects. The public entry point is then required to dispose the contents of the list in a + finally block. + + Managing PrincipalContext Instances + To use the AccountManagement APIs to connect to the local machine or a domain, a + PrincipalContext is needed. + + For the local groups and users, a PrincipalContext reflecting the current user can be + created. + + For the default domain, the domain where the machine is joined, explicit credentials are + needed since the default user context is SYSTEM which has no rights to the domain. + + Additional PrincipalContext instances may be needed when the machine is in a domain that is + part of a multi-domain forest. For example, Microsoft uses a multi-domain forest that + includes domains such as ntdev, redmond, wingroup and a group may have members that + span multiple domains. Unless the enterprise implements the Global Catalog, + something that Microsoft does not do, a unique PrincipalContext is needed to resolve + accounts in each of the domains. + + To manage the use of PrincipalContext across domains, public entry points define a + $principalContextCache hashtable and pass it to support functions that need to resolve a group + or group member. Consumers of a PrincipalContext call Get-PrincipalContext with a scope + (domain name or machine name). Get-PrincipalContext returns an existing hashtable entry or + creates a new entry. Note that a PrincipalContext to a target domain requires connecting + to the domain. The hashtable avoids subsequent connection calls. Also note that + Get-PrincipalContext takes a Credential parameter for the case where a new PrincipalContext + is needed. The implicit assumption is that the credential provided for the primary domain + also has rights to resolve accounts in any of the other domains. + + Resolving Group Members + The original implementation assumed that group members could be resolved using the machine + PrincipalContext or the logged on user. In practice this is not reliable since the resource + is typically run under the SYSTEM account and this account is not guaranteed to have rights + to resolve domain accounts. Additionally, the APIs for enumerating group members do not + provide a facility for passing additional credentials resulting in domain members failing + to resolve. + + To address this, group members are enumerated by first converting the GroupPrincipal to a + DirectoryEntry and enumerating its child members. The returned DirectoryEntry instances are + then resolved to Principal objects using a PrincipalContext appropriate for the target + domain. + + Handling Stale Group Members + A group may have stale members if the machine was moved from one domain to a another + foreign domain or when accounts are deleted (domain or local). At this point, members that + were defined in the original domain or were deleted are now stale and cannot be resolved + using Principal::FindByIdentity. The original implementation failed at this point + preventing any operations against the group. The current implementation calls Write-Warning + with the associated SID of the member that cannot be resolved then continues the operation. +#> + +Set-StrictMode -Version 'Latest' + +Import-Module -Name (Join-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -ChildPath 'CommonResourceHelper.psm1') +$script:localizedData = Get-LocalizedData -ResourceName 'MSFT_GroupResource' + +if (-not (Test-IsNanoServer)) +{ + Add-Type -AssemblyName 'System.DirectoryServices.AccountManagement' +} + +<# + .SYNOPSIS + Retrieves the current state of the group with the specified name. + + .PARAMETER GroupName + The name of the group to retrieve the current state of. + + .PARAMETER Credential + A credential to resolve non-local group members. +#> +function Get-TargetResource +{ + [OutputType([Hashtable])] + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] + $GroupName, + + [System.Management.Automation.PSCredential] + [System.Management.Automation.Credential()] + $Credential + ) + + Assert-GroupNameValid -GroupName $GroupName + + if (Test-IsNanoServer) + { + Write-Verbose -Message ($script:localizedData.InvokingFunctionForGroup -f 'Get-TargetResourceOnNanoServer', $GroupName) + return Get-TargetResourceOnNanoServer @PSBoundParameters + } + else + { + Write-Verbose -Message ($script:localizedData.InvokingFunctionForGroup -f 'Get-TargetResourceOnFullSKU', $GroupName) + return Get-TargetResourceOnFullSKU @PSBoundParameters + } +} + +<# + .SYNOPSIS + Creates, modifies, or removes a group. + + .PARAMETER GroupName + The name of the group to create, modify, or remove. + + .PARAMETER Ensure + Specifies whether the group should exist or not. + + To ensure that the group does exist, set this property to present. + To ensure that the group does not exist, set this property to Absent. + + The default value is Present. + + .PARAMETER Description + The description the group should have. + + .PARAMETER Members + The members the group should have. + + This property will replace all the current group members with the specified members. + + Members should be specified as strings in the format of their domain qualified name + (domain\username), their UPN (username@domainname), their distinguished name (CN=username,DC=...), or their username (for local machine accounts). + + Using either the MembersToExclude or MembersToInclude properties in the same configuration as this property will generate an error. + + .PARAMETER MembersToInclude + The members the group should include. + + This property will only add members to a group. + + Members should be specified as strings in the format of their domain qualified name + (domain\username), their UPN (username@domainname), their distinguished name (CN=username,DC=...), or their username (for local machine accounts). + + Using the Members property in the same configuration as this property will generate an error. + + .PARAMETER MembersToExclude + The members the group should exclude. + + This property will only remove members from a group. + + Members should be specified as strings in the format of their domain qualified name + (domain\username), their UPN (username@domainname), their distinguished name (CN=username,DC=...), or their username (for local machine accounts). + + Using the Members property in the same configuration as this property will generate an error. + + .PARAMETER Credential + A credential to resolve and add non-local group members. + + An error will occur if this account does not have the appropriate Active Directory permissions to add all + non-local accounts to the group. +#> +function Set-TargetResource +{ + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] + $GroupName, + + [ValidateSet('Present', 'Absent')] + [String] + $Ensure = 'Present', + + [String] + $Description, + + [String[]] + $Members, + + [String[]] + $MembersToInclude, + + [String[]] + $MembersToExclude, + + [ValidateNotNullOrEmpty()] + [System.Management.Automation.PSCredential] + [System.Management.Automation.Credential()] + $Credential + ) + + Write-Verbose ($script:localizedData.SetTargetResourceStartMessage -f $GroupName) + + Assert-GroupNameValid -GroupName $GroupName + + if (Test-IsNanoServer) + { + Set-TargetResourceOnNanoServer @PSBoundParameters + } + else + { + Set-TargetResourceOnFullSKU @PSBoundParameters + } + + Write-Verbose ($script:localizedData.SetTargetResourceEndMessage -f $GroupName) +} + +<# + .SYNOPSIS + Tests if the group with the specified name is in the desired state. + + .PARAMETER GroupName + The name of the group to test the state of. + + .PARAMETER Ensure + Indicates if the group should exist or not. + + Set this property to "Absent" to test that the group does not exist. + Setting it to "Present" (the default value) tests that the group exists. + + .PARAMETER Description + The description of the group to test for. + + .PARAMETER Members + The list of members the group should have. + + The value of this property is an array of strings of the formats domain qualified name + (domain\username), UPN (username@domainname), distinguished name (CN=username,DC=...) and/or + a unqualified (username) for local machine accounts. + + If you set this property in a configuration, do not use either the MembersToExclude or + MembersToInclude property. Doing so will generate an error. + + .PARAMETER MembersToInclude + A list of members that should be in the group. + + The value of this property is an array of strings of the formats domain qualified name + (domain\username), UPN (username@domainname), distinguished name (CN=username,DC=...) and/or + a unqualified (username) for local machine accounts. + + If you set this property in a configuration, do not use the Members property. + Doing so will generate an error. + + .PARAMETER MembersToExclude + A list of members that should not be in the group. + + The value of this property is an array of strings of the formats domain qualified name + (domain\username), UPN (username@domainname), distinguished name (CN=username,DC=...) and/or + a unqualified (username) for local machine accounts. + + If you set this property in a configuration, do not use the Members property. + Doing so will generate an error. + + .PARAMETER Credential + The credentials required to resolve non-local group members +#> +function Test-TargetResource +{ + [OutputType([Boolean])] + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] + $GroupName, + + [ValidateSet('Present', 'Absent')] + [String] + $Ensure = 'Present', + + [String] + $Description, + + [String[]] + $Members, + + [String[]] + $MembersToInclude, + + [String[]] + $MembersToExclude, + + [ValidateNotNullOrEmpty()] + [System.Management.Automation.PSCredential] + [System.Management.Automation.Credential()] + $Credential + ) + + Assert-GroupNameValid -GroupName $GroupName + + if (Test-IsNanoServer) + { + Write-Verbose ($script:localizedData.InvokingFunctionForGroup -f 'Test-TargetResourceOnNanoServer', $GroupName) + return Test-TargetResourceOnNanoServer @PSBoundParameters + } + else + { + Write-Verbose ($script:localizedData.InvokingFunctionForGroup -f 'Test-TargetResourceOnFullSKU', $GroupName) + return Test-TargetResourceOnFullSKU @PSBoundParameters + } +} + +<# + .SYNOPSIS + Retrieves the current state of the group with the specified name on a full server. + + .PARAMETER GroupName + The name of the group to retrieve the current state of. + + .PARAMETER Credential + A credential to resolve non-local group members. +#> +function Get-TargetResourceOnFullSKU +{ + [OutputType([Hashtable])] + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] + $GroupName, + + [System.Management.Automation.PSCredential] + [System.Management.Automation.Credential()] + $Credential + ) + + $principalContextCache = @{} + $disposables = New-Object -TypeName 'System.Collections.ArrayList' + + try + { + $principalContext = Get-PrincipalContext ` + -PrincipalContextCache $principalContextCache ` + -Disposables $Disposables ` + -Scope $env:COMPUTERNAME + + $group = Get-Group -GroupName $GroupName -PrincipalContext $principalContext + + if ($null -ne $group) + { + $null = $disposables.Add($group) + + # The group was found. Find the group members. + $members = Get-MembersOnFullSKU -Group $group -PrincipalContextCache $principalContextCache ` + -Credential $Credential -Disposables $disposables + + return @{ + GroupName = $group.Name + Ensure = 'Present' + Description = $group.Description + Members = $members + } + } + else + { + # The group was not found. + return @{ + GroupName = $GroupName + Ensure = 'Absent' + } + } + } + finally + { + Remove-DisposableObject -Disposables $disposables + } +} + +<# + .SYNOPSIS + Retrieves the current state of the group with the specified name on Nano Server. + + .PARAMETER GroupName + The name of the group to retrieve the current state of. + + .PARAMETER Credential + A credential to resolve non-local group members. +#> +function Get-TargetResourceOnNanoServer +{ + [OutputType([Hashtable])] + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] + $GroupName, + + [System.Management.Automation.PSCredential] + [System.Management.Automation.Credential()] + $Credential + ) + + try + { + $group = Get-LocalGroup -Name $GroupName -ErrorAction 'Stop' + } + catch + { + if ($_.CategoryInfo.Reason -eq 'GroupNotFoundException') + { + # The group was not found. + return @{ + GroupName = $GroupName + Ensure = 'Absent' + } + } + + New-InvalidOperationException -ErrorRecord $_ + } + + # The group was found. Find the group members. + $members = Get-MembersOnNanoServer -Group $group + + return @{ + GroupName = $group.Name + Ensure = 'Present' + Description = $group.Description + Members = $members + } +} + +<# + .SYNOPSIS + The Set-TargetResource cmdlet on a full server. + + .PARAMETER GroupName + The name of the group for which you want to ensure a specific state. + + .PARAMETER Ensure + Indicates if the group should exist or not. + + Set this property to Present to ensure that the group exists. + Set this property to Absent to ensure that the group does not exist. + + The default value is Present. + + .PARAMETER Description + The description of the group. + + .PARAMETER Members + Use this property to replace the current group membership with the specified members. + + The value of this property is an array of strings of the formats domain qualified name + (domain\username), UPN (username@domainname), distinguished name (CN=username,DC=...) and/or + an unqualified (username) for local machine accounts. + + If you set this property in a configuration, do not use either the MembersToExclude or + MembersToInclude property. Doing so will generate an error. + + .PARAMETER MembersToInclude + Use this property to add members to the existing membership of the group. + + The value of this property is an array of strings of the formats domain qualified name + (domain\username), UPN (username@domainname), distinguished name (CN=username,DC=...) and/or + a unqualified (username) for local machine accounts. + + If you set this property in a configuration, do not use the Members property. + Doing so will generate an error. + + .PARAMETER MembersToExclude + Use this property to remove members from the existing membership of the group. + + The value of this property is an array of strings of the formats domain qualified name + (domain\username), UPN (username@domainname), distinguished name (CN=username,DC=...) and/or + a unqualified (username) for local machine accounts. + + If you set this property in a configuration, do not use the Members property. + Doing so will generate an error. + + .PARAMETER Credential + The credentials required to access remote resources. Note: This account must have the + appropriate Active Directory permissions to add all non-local accounts to the group. + Otherwise, an error will occur. +#> +function Set-TargetResourceOnFullSKU +{ + [CmdletBinding(SupportsShouldProcess = $true)] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] + $GroupName, + + [ValidateSet('Present', 'Absent')] + [String] + $Ensure = 'Present', + + [String] + $Description, + + [String[]] + $Members, + + [String[]] + $MembersToInclude, + + [String[]] + $MembersToExclude, + + [ValidateNotNullOrEmpty()] + [System.Management.Automation.PSCredential] + [System.Management.Automation.Credential()] + $Credential + ) + + $principalContextCache = @{} + $disposables = New-Object -TypeName 'System.Collections.ArrayList' + + try + { + $principalContext = Get-PrincipalContext ` + -PrincipalContextCache $principalContextCache ` + -Disposables $disposables ` + -Scope $env:computerName + + # Try to find a group by its name. + $group = Get-Group -GroupName $GroupName -PrincipalContext $principalContext + $groupOriginallyExists = $null -ne $group + + if ($Ensure -eq 'Present') + { + $actualMembersAsPrincipals = $null + + $shouldProcessTarget = $script:localizedData.GroupWithName -f $GroupName + if ($groupOriginallyExists) + { + $null = $disposables.Add($group) + $whatIfShouldProcess = $PSCmdlet.ShouldProcess($shouldProcessTarget, $script:localizedData.SetOperation) + + $actualMembersAsPrincipals = @( Get-MembersAsPrincipalsList ` + -Group $group ` + -PrincipalContextCache $principalContextCache ` + -Disposables $disposables ` + -Credential $Credential + ) + } + else + { + $whatIfShouldProcess = $PSCmdlet.ShouldProcess($shouldProcessTarget, $script:localizedData.AddOperation) + } + + if ($whatIfShouldProcess) + { + $saveChanges = $false + + if (-not $groupOriginallyExists) + { + $localPrincipalContext = Get-PrincipalContext -PrincipalContextCache $principalContextCache ` + -Disposables $disposables -Scope $env:COMPUTERNAME + + $group = New-Object -TypeName 'System.DirectoryServices.AccountManagement.GroupPrincipal' ` + -ArgumentList @( $localPrincipalContext ) + $null = $disposables.Add($group) + + $group.Name = $GroupName + $saveChanges = $true + } + + # Set group properties. + + if ($PSBoundParameters.ContainsKey('Description') -and $Description -ne $group.Description) + { + $group.Description = $Description + $saveChanges = $true + } + + <# + Group members can be updated in two ways: + 1. Supplying the Members parameter - this causes the membership to be replaced + with the members defined in Members. + + NOTE: If Members is empty, the group membership is cleared. + + 2. Providing MembersToInclude and/or MembersToExclude + - this adds/removes members from the list. + + If Members is mutually exclusive with MembersToInclude and MembersToExclude + If Members is not defined then MembersToInclude or MembersToExclude + must contain at least one entry. + #> + if ($PSBoundParameters.ContainsKey('Members')) + { + foreach ($incompatibleParameterName in @( 'MembersToInclude', 'MembersToExclude' )) + { + if ($PSBoundParameters.ContainsKey($incompatibleParameterName)) + { + New-InvalidArgumentException -ArgumentName $incompatibleParameterName ` + -Message ($script:localizedData.MembersAndIncludeExcludeConflict -f 'Members', $incompatibleParameterName) + } + } + + if ($Members.Count -eq 0 -and $null -ne $actualMembersAsPrincipals -and $actualMembersAsPrincipals.Count -ne 0) + { + Clear-GroupMembers -Group $group + $saveChanges = $true + } + elseif ($Members.Count -ne 0) + { + # Remove duplicate names as strings. + $uniqueMembers = $Members | Select-Object -Unique + + # Resolve the names to actual principal objects. + $membersAsPrincipals = @( ConvertTo-UniquePrincipalsList ` + -MemberNames $uniqueMembers ` + -PrincipalContextCache $principalContextCache ` + -Disposables $disposables ` + -Credential $Credential ) + + if ($null -ne $actualMembersAsPrincipals -and $actualMembersAsPrincipals.Count -gt 0) + { + foreach ($memberAsPrincipal in $membersAsPrincipals) + { + if ($actualMembersAsPrincipals -notcontains $memberAsPrincipal) + { + Add-GroupMember -Group $group -MemberAsPrincipal $memberAsPrincipal + $saveChanges = $true + } + } + + foreach ($actualMemberAsPrincipal in $actualMembersAsPrincipals) + { + if ($membersAsPrincipals -notcontains $actualMemberAsPrincipal) + { + Remove-GroupMember -Group $group -MemberAsPrincipal $actualMemberAsPrincipal + $saveChanges = $true + } + } + } + else + { + # Set the members of the group + foreach ($memberAsPrincipal in $membersAsPrincipals) + { + Add-GroupMember -Group $group -MemberAsPrincipal $memberAsPrincipal + } + + $saveChanges = $true + } + } + else + { + Write-Verbose -Message ($script:localizedData.GroupAndMembersEmpty -f $GroupName) + } + } + else + { + $membersToIncludeAsPrincipals = $null + $uniqueMembersToInclude = $MembersToInclude | Select-Object -Unique + + if ($null -eq $uniqueMembersToInclude) + { + Write-Verbose -Message $script:localizedData.MembersToIncludeEmpty + } + else + { + # Resolve the names to actual principal objects. + $membersToIncludeAsPrincipals = @( ConvertTo-UniquePrincipalsList ` + -MemberNames $uniqueMembersToInclude ` + -PrincipalContextCache $principalContextCache ` + -Disposables $disposables ` + -Credential $Credential + ) + } + + $membersToExcludeAsPrincipals = $null + $uniqueMembersToExclude = $MembersToExclude | Select-Object -Unique + + if ($null -eq $uniqueMembersToExclude) + { + Write-Verbose -Message $script:localizedData.MembersToExcludeEmpty + } + else + { + # Resolve the names to actual principal objects. + $membersToExcludeAsPrincipals = @( ConvertTo-UniquePrincipalsList ` + -MemberNames $uniqueMembersToExclude ` + -PrincipalContextCache $principalContextCache ` + -Disposables $disposables ` + -Credential $Credential + ) + } + + foreach ($includedPrincipal in $membersToIncludeAsPrincipals) + { + <# + Throw an error if any common principals were provided in MembersToInclude + and MembersToExclude. + #> + if ($membersToExcludeAsPrincipals -contains $includedPrincipal) + { + New-InvalidArgumentException -ArgumentName 'MembersToInclude and MembersToExclude' ` + -Message ($script:localizedData.IncludeAndExcludeConflict -f $includedPrincipal.SamAccountName, + 'MembersToInclude', 'MembersToExclude') + } + + if ($actualMembersAsPrincipals -notcontains $includedPrincipal) + { + Add-GroupMember -Group $group -MemberAsPrincipal $includedPrincipal + $saveChanges = $true + } + } + + foreach ($excludedPrincipal in $membersToExcludeAsPrincipals) + { + if ($actualMembersAsPrincipals -contains $excludedPrincipal) + { + Remove-GroupMember -Group $group -MemberAsPrincipal $excludedPrincipal + $saveChanges = $true + } + } + } + + if ($saveChanges) + { + Save-Group -Group $group + + # Send an operation success verbose message. + if ($groupOriginallyExists) + { + Write-Verbose -Message ($script:localizedData.GroupUpdated -f $GroupName) + } + else + { + Write-Verbose -Message ($script:localizedData.GroupCreated -f $GroupName) + } + } + else + { + Write-Verbose -Message ($script:localizedData.NoConfigurationRequired -f $GroupName) + } + } + } + else + { + if ($groupOriginallyExists) + { + if ($PSCmdlet.ShouldProcess(($script:localizedData.GroupWithName -f $GroupName), $script:localizedData.RemoveOperation)) + { + # Don't add group to $disposables since Delete also disposes. + Remove-Group -Group $group + Write-Verbose -Message ($script:localizedData.GroupRemoved -f $GroupName) + } + else + { + $null = $disposables.Add($group) + } + } + else + { + Write-Verbose -Message ($script:localizedData.NoConfigurationRequiredGroupDoesNotExist -f $GroupName) + } + } + } + finally + { + Remove-DisposableObject -Disposables $disposables + } +} + +<# + .SYNOPSIS + The Set-TargetResource cmdlet on Nano Server. + + .PARAMETER GroupName + The name of the group for which you want to ensure a specific state. + + .PARAMETER Ensure + Indicates if the group should exist or not. + + Set this property to Present to ensure that the group exists. + Set this property to Absent to ensure that the group does not exist. + + The default value is Present. + + .PARAMETER Description + The description of the group. + + .PARAMETER Members + Use this property to replace the current group membership with the specified members. + + The value of this property is an array of strings of the formats domain qualified name + (domain\username), UPN (username@domainname), distinguished name (CN=username,DC=...) and/or + a unqualified (username) for local machine accounts. + + If you set this property in a configuration, do not use either the MembersToExclude or + MembersToInclude property. Doing so will generate an error. + + .PARAMETER MembersToInclude + Use this property to add members to the existing membership of the group. + + The value of this property is an array of strings of the formats domain qualified name + (domain\username), UPN (username@domainname), distinguished name (CN=username,DC=...) and/or + a unqualified (username) for local machine accounts. + + If you set this property in a configuration, do not use the Members property. + Doing so will generate an error. + + .PARAMETER MembersToExclude + Use this property to remove members from the existing membership of the group. + + The value of this property is an array of strings of the formats domain qualified name + (domain\username), UPN (username@domainname), distinguished name (CN=username,DC=...) and/or + a unqualified (username) for local machine accounts. + + If you set this property in a configuration, do not use the Members property. + Doing so will generate an error. + + .PARAMETER Credential + Not used on Nano Server. + Only local users are accessible from the resource. +#> +function Set-TargetResourceOnNanoServer +{ + [CmdletBinding(SupportsShouldProcess = $true)] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] + $GroupName, + + [ValidateSet('Present', 'Absent')] + [String] + $Ensure = 'Present', + + [String] + $Description, + + [String[]] + $Members, + + [String[]] + $MembersToInclude, + + [String[]] + $MembersToExclude, + + [ValidateNotNullOrEmpty()] + [System.Management.Automation.PSCredential] + [System.Management.Automation.Credential()] + $Credential + ) + + try + { + $group = Get-LocalGroup -Name $GroupName -ErrorAction 'Stop' + $groupOriginallyExists = $true + } + catch [System.Exception] + { + if ($_.CategoryInfo.Reason -eq 'GroupNotFoundException') + { + # A group with the provided name does not exist. + Write-Verbose -Message ($script:localizedData.GroupDoesNotExist -f $GroupName) + $groupOriginallyExists = $false + } + else + { + New-InvalidOperationException -ErrorRecord $_ + } + } + + if ($Ensure -eq 'Present') + { + $whatIfShouldProcess = + if ($groupOriginallyExists) + { + $PSCmdlet.ShouldProcess(($script:localizedData.GroupWithName -f $GroupName), + $script:localizedData.SetOperation) + } + else + { + $PSCmdlet.ShouldProcess(($script:localizedData.GroupWithName -f $GroupName), + $script:localizedData.AddOperation) + } + + if ($whatIfShouldProcess) + { + if (-not $groupOriginallyExists) + { + $group = New-LocalGroup -Name $GroupName + Write-Verbose -Message ($script:localizedData.GroupCreated -f $GroupName) + } + + # Set the group properties. + if ($PSBoundParameters.ContainsKey('Description') -and + ((-not $groupOriginallyExists) -or ($Description -ne $group.Description))) + { + Set-LocalGroup -Name $GroupName -Description $Description + } + + $groupMembers = Get-MembersOnNanoServer -Group $group + + if ($PSBoundParameters.ContainsKey('Members')) + { + foreach ($incompatibleParameterName in @( 'MembersToInclude', 'MembersToExclude' )) + { + if ($PSBoundParameters.ContainsKey($incompatibleParameterName)) + { + New-InvalidArgumentException -ArgumentName $incompatibleParameterName ` + -Message ($script:localizedData.MembersAndIncludeExcludeConflict -f 'Members', $incompatibleParameterName) + } + } + + # Remove duplicate names as strings. + $uniqueMembers = $Members | Select-Object -Unique + + # Remove unspecified members + foreach ($groupMember in $groupMembers) + { + if ($uniqueMembers -notcontains $groupMember) + { + Remove-LocalGroupMember -Group $GroupName -Member $groupMember + } + } + + # Add specified missing members + foreach ($uniqueMember in $uniqueMembers) + { + if ($groupMembers -notcontains $uniqueMember) + { + Add-LocalGroupMember -Group $GroupName -Member $uniqueMember + } + } + } + else + { + $uniqueMembersToInclude = $MembersToInclude | Select-Object -Unique + $uniqueMembersToExclude = $MembersToExclude | Select-Object -Unique + + <# + Both MembersToInclude and MembersToExclude were provided. + Check if they have common principals. + #> + foreach ($includedMember in $uniqueMembersToInclude) + { + foreach($excludedMember in $uniqueMembersToExclude) + { + if ($includedMember -eq $excludedMember) + { + New-InvalidArgumentException -ArgumentName 'MembersToInclude and MembersToExclude' ` + -Message ($script:localizedData.IncludeAndExcludeConflict -f $includedMember, 'MembersToInclude', + 'MembersToExclude') + } + } + } + + foreach ($includedMember in $uniqueMembersToInclude) + { + if ($groupMembers -notcontains $includedMember) + { + Add-LocalGroupMember -Group $GroupName -Member $includedMember + } + } + + foreach($excludedMember in $uniqueMembersToExclude) + { + if ($groupMembers -contains $excludedMember) + { + Remove-LocalGroupMember -Group $GroupName -Member $excludedMember + } + } + } + } + } + else + { + # Ensure is set to "Absent". + if ($groupOriginallyExists) + { + $whatIfShouldProcess = $PSCmdlet.ShouldProcess( + ($script:localizedData.GroupWithName -f $GroupName), $script:localizedData.RemoveOperation) + if ($whatIfShouldProcess) + { + # The group exists. Remove the group by the provided name. + Remove-LocalGroup -Name $GroupName + Write-Verbose -Message ($script:localizedData.GroupRemoved -f $GroupName) + } + } + else + { + Write-Verbose -Message ($script:localizedData.NoConfigurationRequiredGroupDoesNotExist -f $GroupName) + } + } +} + +<# + .SYNOPSIS + The Test-TargetResource cmdlet on a full server. + Tests if the group being managed is in the desired state. + + .PARAMETER GroupName + The name of the group for which you want to test a specific state. + + .PARAMETER Ensure + Indicates if the group should exist or not. + + Set this property to Present to ensure that the group exists. + Set this property to Absent to ensure that the group does not exist. + + The default value is Present. + + .PARAMETER Description + The description of the group to test for. + + .PARAMETER Members + Use this property to test if the existing membership of the group matches + the list provided. + + The value of this property is an array of strings of the formats domain qualified name + (domain\username), UPN (username@domainname), distinguished name (CN=username,DC=...) and/or + a unqualified (username) for local machine accounts. + + If you set this property in a configuration, do not use either the MembersToExclude or + MembersToInclude property. Doing so will generate an error. + + .PARAMETER MembersToInclude + Use this property to test if members need to be added to the existing membership + of the group. + + The value of this property is an array of strings of the formats domain qualified name + (domain\username), UPN (username@domainname), distinguished name (CN=username,DC=...) and/or + a unqualified (username) for local machine accounts. + + If you set this property in a configuration, do not use the Members property. + Doing so will generate an error. + + .PARAMETER MembersToExclude + Use this property to test if members need to removed from the existing membership + of the group. + + The value of this property is an array of strings of the formats domain qualified name + (domain\username), UPN (username@domainname), distinguished name (CN=username,DC=...) and/or + a unqualified (username) for local machine accounts. + + If you set this property in a configuration, do not use the Members property. + Doing so will generate an error. + + .PARAMETER Credential + The credentials required to resolve non-local group members +#> +function Test-TargetResourceOnFullSKU +{ + [OutputType([Boolean])] + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] + $GroupName, + + [ValidateSet('Present', 'Absent')] + [String] + $Ensure = 'Present', + + [String] + $Description, + + [ValidateNotNull()] + [String[]] + $Members, + + [String[]] + $MembersToInclude, + + [String[]] + $MembersToExclude, + + [ValidateNotNullOrEmpty()] + [System.Management.Automation.PSCredential] + [System.Management.Automation.Credential()] + $Credential + ) + + $principalContextCache = @{} + $disposables = New-Object -TypeName 'System.Collections.ArrayList' + + try + { + $principalContext = Get-PrincipalContext ` + -PrincipalContextCache $PrincipalContextCache ` + -Disposables $Disposables ` + -Scope $env:computerName + + $group = Get-Group -GroupName $GroupName -PrincipalContext $principalContext + + if ($null -eq $group) + { + Write-Verbose -Message ($script:localizedData.GroupDoesNotExist -f $GroupName) + return $Ensure -eq 'Absent' + } + + $null = $disposables.Add($group) + Write-Verbose -Message ($script:localizedData.GroupExists -f $GroupName) + + # Validate separate properties. + if ($Ensure -eq 'Absent') + { + Write-Verbose -Message ($script:localizedData.PropertyMismatch -f 'Ensure', 'Absent', 'Present') + return $false + } + + if ($PSBoundParameters.ContainsKey('Description') -and $Description -ne $group.Description) + { + Write-Verbose -Message ($script:localizedData.PropertyMismatch -f 'Description', $Description, $group.Description) + return $false + } + + $actualMembersAsPrincipals = @( Get-MembersAsPrincipalsList ` + -Group $group ` + -PrincipalContextCache $principalContextCache ` + -Disposables $disposables ` + -Credential $Credential + ) + + if ($PSBoundParameters.ContainsKey('Members')) + { + foreach ($incompatibleParameterName in @( 'MembersToInclude', 'MembersToExclude' )) + { + if ($PSBoundParameters.ContainsKey($incompatibleParameterName)) + { + New-InvalidArgumentException -ArgumentName $incompatibleParameterName ` + -Message ($script:localizedData.MembersAndIncludeExcludeConflict -f 'Members', $incompatibleParameterName) + } + } + + $uniqueMembers = $Members | Select-Object -Unique + + if ($null -eq $uniqueMembers) + { + return ($null -eq $actualMembersAsPrincipals -or $actualMembersAsPrincipals.Count -eq 0) + } + else + { + if ($null -eq $actualMembersAsPrincipals -or $actualMembersAsPrincipals.Count -eq 0) + { + return $false + } + + # Resolve the names to actual principal objects. + $expectedMembersAsPrincipals = @( ConvertTo-UniquePrincipalsList ` + -MemberNames $uniqueMembers ` + -PrincipalContextCache $principalContextCache ` + -Disposables $disposables ` + -Credential $Credential + ) + + if ($expectedMembersAsPrincipals.Count -ne $actualMembersAsPrincipals.Count) + { + Write-Verbose -Message ($script:localizedData.MembersNumberMismatch -f 'Members', + $expectedMembersAsPrincipals.Count, $actualMembersAsPrincipals.Count) + return $false + } + + # Compare the two member lists. + foreach ($expectedMemberAsPrincipal in $expectedMembersAsPrincipals) + { + if ($actualMembersAsPrincipals -notcontains $expectedMemberAsPrincipal) + { + Write-Verbose -Message ($script:localizedData.MembersMemberMismatch -f $expectedMemberAsPrincipal.SamAccountName, + 'Members', $group.SamAccountName) + return $false + } + } + } + } + else + { + $membersToIncludeAsPrincipals = $null + $uniqueMembersToInclude = $MembersToInclude | Select-Object -Unique + + if ($null -eq $uniqueMembersToInclude) + { + Write-Verbose -Message $script:localizedData.MembersToIncludeEmpty + } + else + { + # Resolve the names to actual principal objects. + $membersToIncludeAsPrincipals = @( ConvertTo-UniquePrincipalsList ` + -MemberNames $uniqueMembersToInclude ` + -PrincipalContextCache $principalContextCache ` + -Disposables $disposables ` + -Credential $Credential + ) + } + + $membersToExcludeAsPrincipals = $null + $uniqueMembersToExclude = $MembersToExclude | Select-Object -Unique + + if ($null -eq $uniqueMembersToExclude) + { + Write-Verbose -Message $script:localizedData.MembersToExcludeEmpty + } + else + { + # Resolve the names to actual principal objects. + $membersToExcludeAsPrincipals = @( ConvertTo-UniquePrincipalsList ` + -MemberNames $uniqueMembersToExclude ` + -PrincipalContextCache $principalContextCache ` + -Disposables $disposables ` + -Credential $Credential + ) + } + + foreach ($includedPrincipal in $membersToIncludeAsPrincipals) + { + <# + Throw an error if any common principals were provided in MembersToInclude + and MembersToExclude. + #> + if ($membersToExcludeAsPrincipals -contains $includedPrincipal) + { + New-InvalidArgumentException -ArgumentName 'MembersToInclude and MembersToExclude' ` + -Message ($script:localizedData.IncludeAndExcludeConflict -f $includedPrincipal.SamAccountName, + 'MembersToInclude', 'MembersToExclude') + } + + if ($actualMembersAsPrincipals -notcontains $includedPrincipal) + { + return $false + } + } + + foreach ($excludedPrincipal in $membersToExcludeAsPrincipals) + { + if ($actualMembersAsPrincipals -contains $excludedPrincipal) + { + return $false + } + } + } + } + finally + { + Remove-DisposableObject -Disposables $disposables + } + + return $true +} + +<# + .SYNOPSIS + The Test-TargetResource cmdlet on a Nano server + Tests if the group being managed is in the desired state. + + .PARAMETER GroupName + The name of the group for which you want to test a specific state. + + .PARAMETER Ensure + Indicates if the group should exist or not. + + Set this property to Present to ensure that the group exists. + Set this property to Absent to ensure that the group does not exist. + + The default value is Present. + + .PARAMETER Description + The description of the group to test for. + + .PARAMETER Members + Use this property to test if the existing membership of the group matches + the list provided. + + The value of this property is an array of strings of the formats domain qualified name + (domain\username), UPN (username@domainname), distinguished name (CN=username,DC=...) and/or + a unqualified (username) for local machine accounts. + + If you set this property in a configuration, do not use either the MembersToExclude or + MembersToInclude property. Doing so will generate an error. + + .PARAMETER MembersToInclude + Use this property to test if members need to be added to the existing membership + of the group. + + The value of this property is an array of strings of the formats domain qualified name + (domain\username), UPN (username@domainname), distinguished name (CN=username,DC=...) and/or + a unqualified (username) for local machine accounts. + + If you set this property in a configuration, do not use the Members property. + Doing so will generate an error. + + .PARAMETER MembersToExclude + Use this property to test if members need to removed from the existing membership + of the group. + + The value of this property is an array of strings of the formats domain qualified name + (domain\username), UPN (username@domainname), distinguished name (CN=username,DC=...) and/or + a unqualified (username) for local machine accounts. + + If you set this property in a configuration, do not use the Members property. + Doing so will generate an error. + + .PARAMETER Credential + The credentials required to resolve non-local group members +#> +function Test-TargetResourceOnNanoServer +{ + [OutputType([Boolean])] + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] + $GroupName, + + [ValidateSet('Present', 'Absent')] + [String] + $Ensure = 'Present', + + [String] + $Description, + + [ValidateNotNull()] + [String[]] + $Members, + + [String[]] + $MembersToInclude, + + [String[]] + $MembersToExclude, + + [ValidateNotNullOrEmpty()] + [System.Management.Automation.PSCredential] + [System.Management.Automation.Credential()] + $Credential + ) + + try + { + $group = Get-LocalGroup -Name $GroupName -ErrorAction Stop + } + catch [System.Exception] + { + if ($_.CategoryInfo.Reason -eq 'GroupNotFoundException') + { + # A group with the provided name does not exist. + Write-Verbose -Message ($script:localizedData.GroupDoesNotExist -f $GroupName) + + return ($Ensure -eq 'Absent') + } + + New-InvalidOperationException -ErrorRecord $_ + } + + # A group with the provided name exists. + Write-Verbose -Message ($script:localizedData.GroupExists -f $GroupName) + + # Validate separate properties. + if ($Ensure -eq 'Absent') + { + Write-Verbose -Message ($script:localizedData.PropertyMismatch -f 'Ensure', 'Absent', 'Present') + return $false + } + + if ($PSBoundParameters.ContainsKey('Description') -and $Description -ne $group.Description) + { + Write-Verbose -Message ($script:localizedData.PropertyMismatch -f 'Description', $Description, $group.Description) + return $false + } + + $groupMembers = Get-MembersOnNanoServer -Group $group + + if ($PSBoundParameters.ContainsKey('Members')) + { + foreach ($incompatibleParameterName in @( 'MembersToInclude', 'MembersToExclude' )) + { + if ($PSBoundParameters.ContainsKey($incompatibleParameterName)) + { + New-InvalidArgumentException -ArgumentName $incompatibleParameterName ` + -Message ($script:localizedData.MembersAndIncludeExcludeConflict -f 'Members', $incompatibleParameterName) + } + } + + # Remove duplicate names as strings. + $uniqueMembers = $Members | Select-Object -Unique + + if ($null -eq $uniqueMembers) + { + return ($null -eq $groupMembers -or $groupMembers.Count -eq 0) + } + else + { + if ($null -eq $groupMembers -or $uniqueMembers.Count -ne $groupMembers.Count) + { + return $false + } + + foreach ($groupMember in $groupMembers) + { + if ($uniqueMembers -notcontains $groupMember) + { + return $false + } + } + } + } + else + { + $uniqueMembersToInclude = $MembersToInclude | Select-Object -Unique + $uniqueMembersToExclude = $MembersToExclude | Select-Object -Unique + + <# + Both MembersToInclude and MembersToExclude were provided. + Check if they have common principals. + #> + foreach ($includedMember in $uniqueMembersToInclude) + { + foreach($excludedMember in $uniqueMembersToExclude) + { + if ($includedMember -eq $excludedMember) + { + New-InvalidArgumentException -ArgumentName 'MembersToInclude and MembersToExclude' ` + -Message ($script:localizedData.IncludeAndExcludeConflict -f $includedMember, 'MembersToInclude', + 'MembersToExclude') + } + } + } + + foreach ($includedMember in $uniqueMembersToInclude) + { + if ($groupMembers -notcontains $includedMember) + { + return $false + } + } + + foreach($excludedMember in $uniqueMembersToExclude) + { + if ($groupMembers -contains $excludedMember) + { + return $false + } + } + } + + # All properties match. Return $true. + return $true +} + +<# + .SYNOPSIS + Retrieves the members of a group on a Nano server. + + .PARAMETER Group + The LocalGroup Object to retrieve members for. +#> +function Get-MembersOnNanoServer +{ + [OutputType([System.String[]])] + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNull()] + [Microsoft.PowerShell.Commands.LocalGroup] + $Group + ) + + $localMemberNames = New-Object -TypeName 'System.Collections.ArrayList' + + # Get the group members. + $groupMembers = Get-LocalGroupMember -Group $Group + + foreach ($groupMember in $groupMembers) + { + if ($groupMember.PrincipalSource -ieq 'Local') + { + $localMemberName = $groupMember.Name.Substring($groupMember.Name.IndexOf('\') + 1) + $null = $localMemberNames.Add($localMemberName) + } + else + { + Write-Verbose -Message ($script:localizedData.MemberIsNotALocalUser -f $groupMember.Name, + $groupMember.PrincipalSource) + } + } + + return $localMemberNames.ToArray() +} + +<# + .SYNOPSIS + Retrieves the members of the given a group on a full server. + + .PARAMETER Group + The GroupPrincipal Object to retrieve members for. + + .PARAMETER PrincipalContextCache + A hashtable cache of PrincipalContext instances for each scope. + This is used to cache PrincipalContext instances for cases where it is used multiple times. + + .PARAMETER Disposables + The ArrayList of disposable objects to which to add any objects that need to be disposed. + + .PARAMETER Credential + The network credential to use when explicit credentials are needed for the target domain. +#> +function Get-MembersOnFullSKU +{ + [OutputType([System.String[]])] + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNull()] + [System.DirectoryServices.AccountManagement.GroupPrincipal] + $Group, + + [Parameter(Mandatory = $true)] + [ValidateNotNull()] + [Hashtable] + [AllowEmptyCollection()] + $PrincipalContextCache, + + [Parameter(Mandatory = $true)] + [ValidateNotNull()] + [System.Collections.ArrayList] + [AllowEmptyCollection()] + $Disposables, + + [System.Management.Automation.PSCredential] + [System.Management.Automation.Credential()] + $Credential + ) + + $members = New-Object -TypeName 'System.Collections.ArrayList' + + $membersAsPrincipals = @( Get-MembersAsPrincipalsList ` + -Group $Group ` + -PrincipalContextCache $PrincipalContextCache ` + -Disposables $Disposables ` + -Credential $Credential + ) + + foreach ($memberAsPrincipal in $membersAsPrincipals) + { + if ($memberAsPrincipal.ContextType -eq [System.DirectoryServices.AccountManagement.ContextType]::Domain) + { + # Select only the first part of the full domain name. + $domainName = $memberAsPrincipal.Context.Name + + $domainNameDotIndex = $domainName.IndexOf('.') + if ($domainNameDotIndex -ne -1) + { + $domainName = $domainName.Substring(0, $domainNameDotIndex) + } + + if ($memberAsPrincipal.StructuralObjectClass -ieq 'computer') + { + $null = $members.Add($domainName + '\' + $memberAsPrincipal.Name) + } + else + { + $null = $members.Add($domainName + '\' + $memberAsPrincipal.SamAccountName) + } + } + else + { + $null = $members.Add($memberAsPrincipal.Name) + } + } + + return $members.ToArray() +} + +<# + .SYNOPSIS + Retrieves the members of a group as Principal instances. + + .PARAMETER Group + The group to retrieve members for. + + .PARAMETER PrincipalContextCache + A hashtable cache of PrincipalContext instances for each scope. + This is used to cache PrincipalContext instances for cases where it is used multiple times. + + .PARAMETER Disposables + The ArrayList of disposable objects to which to add any objects that need to be disposed. + + .PARAMETER Credential + The network credential to use when explicit credentials are needed for the target domain. +#> +function Get-MembersAsPrincipalsList +{ + [OutputType([System.DirectoryServices.AccountManagement.Principal[]])] + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNull()] + [System.DirectoryServices.AccountManagement.GroupPrincipal] + $Group, + + [Parameter(Mandatory = $true)] + [ValidateNotNull()] + [Hashtable] + [AllowEmptyCollection()] + $PrincipalContextCache, + + [Parameter(Mandatory = $true)] + [ValidateNotNull()] + [System.Collections.ArrayList] + [AllowEmptyCollection()] + $Disposables, + + [System.Management.Automation.PSCredential] + [System.Management.Automation.Credential()] + $Credential + ) + + $principals = New-Object -TypeName 'System.Collections.ArrayList' + + <# + This logic enumerates the group members using the underlying DirectoryEntry API. This is + needed because enumerating the group members as principal instances causes a resolve to + occur. Since there is no facility for passing credentials to perform the resolution, any + members that cannot be resolved using the current user will fail (such as when this + resource runs as SYSTEM). Dropping down to the underyling DirectoryEntry API allows us to + access the account's SID which can then be used to resolve the associated principal using + explicit credentials. + #> + $groupDirectoryMembers = Get-GroupMembersFromDirectoryEntry -Group $Group + + foreach ($groupDirectoryMember in $groupDirectoryMembers) + { + # Extract the ObjectSid from the underlying DirectoryEntry + $memberDirectoryEntry = New-Object -TypeName 'System.DirectoryServices.DirectoryEntry' ` + -ArgumentList @( $groupDirectoryMember ) + $null = $disposables.Add($memberDirectoryEntry) + + $memberDirectoryEntryPathParts = $memberDirectoryEntry.Path.Split('/') + + if ($memberDirectoryEntryPathParts.Count -eq 4) + { + # Parsing WinNT://domainname/accountname or WinNT://machinename/accountname + $scope = $memberDirectoryEntryPathParts[2] + $accountName = $memberDirectoryEntryPathParts[3] + } + elseif ($memberDirectoryEntryPathParts.Count -eq 5) + { + # Parsing WinNT://domainname/machinename/accountname + $scope = $memberDirectoryEntryPathParts[3] + $accountName = $memberDirectoryEntryPathParts[4] + } + else + { + <# + The account is stale either becuase it was deleted or the machine was moved to a + new domain without removing the domain members from the group. If we consider this + a fatal error, the group is no longer managable by the DSC resource. Writing a + warning allows the operation to complete while leaving the stale member in the + group. + #> + Write-Warning -Message ($script:localizedData.MemberNotValid -f $memberDirectoryEntry.Path) + continue + } + + $principalContext = Get-PrincipalContext ` + -Scope $scope ` + -Credential $Credential ` + -PrincipalContextCache $PrincipalContextCache ` + -Disposables $Disposables + + # If local machine qualified, get the PrincipalContext for the local machine + if (Test-IsLocalMachine -Scope $scope) + { + Write-Verbose -Message ($script:localizedData.ResolvingLocalAccount -f $accountName) + } + # The account is domain qualified - credential required to resolve it. + elseif ($null -ne $principalContext) + { + Write-Verbose -Message ($script:localizedData.ResolvingDomainAccount -f $accountName, $scope) + } + else + { + <# + The provided name is not scoped to the local machine and no credential was + provided. This is an unsupported use case. A credential is required to resolve + off-box. + #> + New-InvalidArgumentException -ArgumentName 'Credential' ` + -Message ($script:localizedData.DomainCredentialsRequired -f $accountName) + } + + # Create a SID to enable comparison againt the expected member's SID. + $memberSidBytes = $memberDirectoryEntry.Properties['ObjectSid'].Value + $memberSid = New-Object -TypeName 'System.Security.Principal.SecurityIdentifier' ` + -ArgumentList @( $memberSidBytes, 0 ) + + $principal = Resolve-SidToPrincipal -PrincipalContext $principalContext -Sid $memberSid -Scope $scope + $null = $disposables.Add($principal) + + $null = $principals.Add($principal) + } + + return $principals.ToArray() +} + +<# + .SYNOPSIS + Throws an error if a group name contains invalid characters. + + .PARAMETER GroupName + The group name to test. +#> +function Assert-GroupNameValid +{ + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] + $GroupName + ) + + $invalidCharacters = @( '\', '/', '"', '[', ']', ':', '|', '<', '>', '+', '=', ';', ',', '?', '*', '@' ) + + if ($GroupName.IndexOfAny($invalidCharacters) -ne -1) + { + New-InvalidArgumentException -ArgumentName 'GroupName' ` + -Message ($script:localizedData.InvalidGroupName -f $GroupName, [String]::Join(' ', $invalidCharacters)) + } + + $nameContainsOnlyWhitspaceOrDots = $true + + # Check if the name consists of only periods and/or white spaces. + for ($groupNameIndex = 0; $groupNameIndex -lt $GroupName.Length; $groupNameIndex++) + { + if (-not [Char]::IsWhiteSpace($GroupName, $groupNameIndex) -and $GroupName[$groupNameIndex] -ne '.') + { + $nameContainsOnlyWhitspaceOrDots = $false + break + } + } + + if ($nameContainsOnlyWhitspaceOrDots) + { + New-InvalidArgumentException -ArgumentName 'GroupName' ` + -Message ($script:localizedData.InvalidGroupName -f $GroupName, [String]::Join(' ', $invalidCharacters)) + } +} + +<# + .SYNOPSIS + Resolves an array of member names to Principal instances. + + .PARAMETER MemberNames + The member names to convert to Principal instances. + + .PARAMETER PrincipalContextCache + A hashtable cache of PrincipalContext instances for each scope. + This is used to cache PrincipalContext instances for cases where it is used multiple times. + + .PARAMETER Disposables + The ArrayList of disposable objects to which to add any objects that need to be disposed. + + .PARAMETER Credential + The network credential to use when explicit credentials are needed for the target domain. +#> +function ConvertTo-UniquePrincipalsList +{ + [OutputType([System.DirectoryServices.AccountManagement.Principal[]])] + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [String[]] + $MemberNames, + + [Parameter(Mandatory = $true)] + [ValidateNotNull()] + [Hashtable] + [AllowEmptyCollection()] + $PrincipalContextCache, + + [Parameter(Mandatory = $true)] + [ValidateNotNull()] + [System.Collections.ArrayList] + [AllowEmptyCollection()] + $Disposables, + + [System.Management.Automation.PSCredential] + [System.Management.Automation.Credential()] + $Credential + ) + + $principals = @() + + foreach ($memberName in $MemberNames) + { + $principal = ConvertTo-Principal ` + -MemberName $memberName ` + -PrincipalContextCache $PrincipalContextCache ` + -Disposables $Disposables ` + -Credential $Credential + + if ($null -ne $principal) + { + # Do not add duplicate entries + if ($principal.ContextType -eq [System.DirectoryServices.AccountManagement.ContextType]::Domain) + { + $duplicatePrincipal = $principals | Where-Object -FilterScript { $_.DistinguishedName -ieq $principal.DistinguishedName } + + if ($null -eq $duplicatePrincipal) + { + $principals += $principal + } + } + else + { + $duplicatePrincipal = $principals | Where-Object -FilterScript { $_.SamAccountName -ieq $principal.SamAccountName } + + if ($null -eq $duplicatePrincipal) + { + $principals += $principal + } + } + } + } + + return $principals +} + +<# + .SYNOPSIS + Resolves a member name to a Principal instance. + + .PARAMETER MemberName + The member name to convert to a Principal instance. + + .PARAMETER PrincipalContextCache + A hashtable cache of PrincipalContext instances for each scope. + This is used to cache PrincipalContext instances for cases where it is used multiple times. + + .PARAMETER Disposables + The ArrayList of disposable objects to which to add any objects that need to be disposed. + + .PARAMETER Credential + The network credential to use when explicit credentials are needed for the target domain. + + .NOTES + ConvertTo-Principal will fail if a machine name is specified as domainname\machinename. It + will succeed if the machine name is specified as the SAM name (domainname\machinename$) or + as the unqualified machine name. + + Split-MemberName splits the scope and account name to avoid this problem. +#> +function ConvertTo-Principal +{ + [OutputType([System.DirectoryServices.AccountManagement.Principal])] + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNull()] + [String] + $MemberName, + + [Parameter(Mandatory = $true)] + [ValidateNotNull()] + [Hashtable] + [AllowEmptyCollection()] + $PrincipalContextCache, + + [Parameter(Mandatory = $true)] + [ValidateNotNull()] + [System.Collections.ArrayList] + [AllowEmptyCollection()] + $Disposables, + + [System.Management.Automation.PSCredential] + [System.Management.Automation.Credential()] + $Credential + ) + + # The scope of the the object name when in the form of scope\name, UPN, or DN + $scope, $identityValue = Split-MemberName -MemberName $MemberName + + if (Test-IsLocalMachine -Scope $scope) + { + # If local machine qualified, get the PrincipalContext for the local machine + Write-Verbose -Message ($script:localizedData.ResolvingLocalAccount -f $identityValue) + } + elseif ($null -ne $Credential) + { + # The account is domain qualified - a credential is provided to resolve it. + Write-Verbose -Message ($script:localizedData.ResolvingDomainAccount -f $identityValue, $scope) + } + else + { + <# + The provided name is not scoped to the local machine and no credentials were provided. + If the object is a domain qualified name, we can try to resolve the user with domain + trust, if setup. When using domain trust, we use the object name to resolve. Object + name can be in different formats such as a domain qualified name, UPN, or a + distinguished name for the scope + #> + + Write-Verbose -Message ($script:localizedData.ResolvingDomainAccountWithTrust -f $MemberName) + $identityValue = $MemberName + } + + $principalContext = Get-PrincipalContext ` + -Scope $scope ` + -PrincipalContextCache $PrincipalContextCache ` + -Disposables $Disposables ` + -Credential $Credential + + try + { + $principal = Find-Principal -PrincipalContext $principalContext -IdentityValue $identityValue + } + catch [System.Runtime.InteropServices.COMException] + { + New-InvalidArgumentException -ArgumentName $MemberName ` + -Message ( $script:localizedData.UnableToResolveAccount -f $MemberName, $_.Exception.Message, $_.Exception.HResult ) + } + + if ($null -eq $principal) + { + New-InvalidArgumentException -ArgumentName $MemberName -Message ($script:localizedData.CouldNotFindPrincipal -f $MemberName) + } + + return $principal +} + +<# + .SYNOPSIS + Resolves a SID to a principal. + + .PARAMETER Sid + The security identifier to resolve to a Principal. + + .PARAMETER PrincipalContext + The PrincipalContext to use to resolve the Principal. + + .PARAMETER Scope + The scope of the PrincipalContext. +#> +function Resolve-SidToPrincipal +{ + [OutputType([System.DirectoryServices.AccountManagement.Principal])] + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNull()] + [System.Security.Principal.SecurityIdentifier] + $Sid, + + [Parameter(Mandatory = $true)] + [ValidateNotNull()] + [System.DirectoryServices.AccountManagement.PrincipalContext] + $PrincipalContext, + + [Parameter(Mandatory = $true)] + [String] + $Scope + ) + + $principal = Find-Principal -PrincipalContext $PrincipalContext -IdentityValue $Sid.Value -IdentityType ([System.DirectoryServices.AccountManagement.IdentityType]::Sid) + + if ($null -eq $principal) + { + if (Test-IsLocalMachine -Scope $Scope) + { + New-InvalidArgumentException -ArgumentName 'Members, MembersToInclude, or MembersToExclude' -Message ($script:localizedData.CouldNotFindPrincipal -f $Sid.Value) + } + else + { + New-InvalidArgumentException -ArgumentName 'Members, MembersToInclude, MembersToExclude, or Credential' -Message ($script:localizedData.CouldNotFindPrincipal -f $Sid.Value) + } + } + + return $principal +} + +<# + .SYNOPSIS + Retrieves a PrincipalContext to use to resolve an object in the given scope. + + .PARAMETER Scope + The scope to retrieve the principal context for. + + .PARAMETER Credential + The network credential to use when explicit credentials are needed for the target domain. + + .PARAMETER PrincipalContextCache + A hashtable cache of PrincipalContext instances for each scope. + This is used to cache PrincipalContext instances for cases where it is used multiple times. + + .PARAMETER Disposables + The ArrayList of disposable objects to which to add any objects that need to be disposed. + + .NOTES + When a new PrincipalContext is created, it is added to the Disposables list + as well as the PrincipalContextCache. +#> +function Get-PrincipalContext +{ + [OutputType([System.DirectoryServices.AccountManagement.PrincipalContext])] + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] + $Scope, + + [System.Management.Automation.PSCredential] + [System.Management.Automation.Credential()] + $Credential, + + [Parameter(Mandatory = $true)] + [ValidateNotNull()] + [Hashtable] + [AllowEmptyCollection()] + $PrincipalContextCache, + + [Parameter(Mandatory = $true)] + [ValidateNotNull()] + [System.Collections.ArrayList] + [AllowEmptyCollection()] + $Disposables + ) + + $principalContext = $null + + if (Test-IsLocalMachine -Scope $Scope) + { + # Check for a cached PrincipalContext for the local machine. + if ($PrincipalContextCache.ContainsKey($env:computerName)) + { + $principalContext = $PrincipalContextCache[$env:computerName] + } + else + { + # Create a PrincipalContext for the local machine + $principalContext = New-Object -TypeName 'System.DirectoryServices.AccountManagement.PrincipalContext' ` + -ArgumentList @( [System.DirectoryServices.AccountManagement.ContextType]::Machine ) + + # Cache the PrincipalContext for this scope for subsequent calls. + $null = $PrincipalContextCache.Add($env:computerName, $principalContext) + $null = $Disposables.Add($principalContext) + } + } + elseif ($PrincipalContextCache.ContainsKey($Scope)) + { + $principalContext = $PrincipalContextCache[$Scope] + } + elseif ($null -ne $Credential) + { + # Create a PrincipalContext targeting $Scope using the network credentials that were passed in. + $credentialDomain = $Credential.GetNetworkCredential().Domain + $credentialUserName = $Credential.GetNetworkCredential().UserName + if ($credentialDomain -ne [String]::Empty) + { + $principalContextName = "$credentialDomain\$credentialUserName" + } + else + { + $principalContextName = $credentialUserName + } + + $principalContext = New-Object -TypeName 'System.DirectoryServices.AccountManagement.PrincipalContext' ` + -ArgumentList @( [System.DirectoryServices.AccountManagement.ContextType]::Domain, $Scope, + $principalContextName, $Credential.GetNetworkCredential().Password ) + + # Cache the PrincipalContext for this scope for subsequent calls. + $null = $PrincipalContextCache.Add($Scope, $principalContext) + $null = $Disposables.Add($principalContext) + } + else + { + # Get a PrincipalContext for the current user in the target domain (even for local System account). + $principalContext = New-Object -TypeName 'System.DirectoryServices.AccountManagement.PrincipalContext' ` + -ArgumentList @( [System.DirectoryServices.AccountManagement.ContextType]::Domain, $Scope ) + + # Cache the PrincipalContext for this scope for subsequent calls. + $null = $PrincipalContextCache.Add($Scope, $principalContext) + $null = $Disposables.Add($principalContext) + } + + return $principalContext +} + +<# + .SYNOPSIS + Determines if a scope represents the current machine. + + .PARAMETER Scope + The scope to test. +#> +function Test-IsLocalMachine +{ + [OutputType([Boolean])] + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] + $Scope + ) + + $localMachineScopes = @( '.', $env:computerName, 'localhost', '127.0.0.1' ) + + if ($localMachineScopes -icontains $Scope) + { + return $true + } + + <# + Determine if we have an ip address that matches an ip address on one of the network + adapters. This is likely overkill. Consider removing it. + #> + if ($Scope.Contains('.')) + { + $win32NetworkAdapterConfigurations = @( Get-CimInstance -ClassName 'Win32_NetworkAdapterConfiguration' ) + foreach ($win32NetworkAdapterConfiguration in $win32NetworkAdapterConfigurations) + { + if ($null -ne $win32NetworkAdapterConfiguration.IPAddress) + { + foreach ($ipAddress in $win32NetworkAdapterConfiguration.IPAddress) + { + if ($ipAddress -eq $Scope) + { + return $true + } + } + } + } + } + + return $false +} + +<# + .SYNOPSIS + Splits a member name into the scope and the account name. + + + .DESCRIPTION + The returned $scope is used to determine where to perform the resolution, the local machine + or a target domain. The returned $accountName is the name of the account to resolve. + + The following details the formats that are handled as well as how the values are + determined: + + Domain Qualified Names: (domainname\username) + + The value is split on the first '\' character with the left hand side returned as the scope + and the right hand side returned as the account name. + + UPN: (username@domainname) + + The value is split on the first '@' character with the left hand side returned as the + account name and the right hand side returned as the scope. + + Distinguished Name: + + The value at the first occurance of 'DC=' is used to extract the unqualified domain name. + The incoming string is returned, as is, for the account name. + + Unqualified Account Names: + + The incoming string is returned as the account name and the local machine name is returned + as the scope. Note that values that do not fall into the above categories are interpreted + as unqualified account names. + + .PARAMETER MemberName + The full name of the member to split. + + .NOTES + ConvertTo-Principal will fail if a machine name is specified as domainname\machinename. It + will succeed if the machine name is specified as the SAM name (domainname\machinename$) or + as the unqualified machine name. + + Split-MemberName splits the scope and account name to avoid this problem. +#> +function Split-MemberName +{ + [OutputType([System.String[]])] + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] + $MemberName + ) + + # Assume no scope is defined or $FullName is a DistinguishedName + $scope = $env:computerName + $accountName = $MemberName + + # Parse domain or machine qualified account name + $separatorIndex = $MemberName.IndexOf('\') + if ($separatorIndex -ne -1) + { + $scope = $MemberName.Substring(0, $separatorIndex) + + if (Test-IsLocalMachine -Scope $scope) + { + $scope = $env:computerName + } + + $accountName = $MemberName.Substring($separatorIndex + 1) + + return [System.String[]] @( $scope, $accountName ) + } + + # Parse UPN for the scope + $separatorIndex = $MemberName.IndexOf('@') + if ($separatorIndex -ne -1) + { + $scope = $MemberName.Substring($separatorIndex + 1) + $accountName = $MemberName.Substring(0, $separatorIndex) + + return [System.String[]] @( $scope, $accountName ) + } + + # Parse distinguished name for the scope + $distinguishedNamePrefix = 'DC=' + + $separatorIndex = $MemberName.IndexOf($distinguishedNamePrefix, [System.StringComparison]::OrdinalIgnoreCase) + if ($separatorIndex -ne -1) + { + <# + For member names in the distinguished name format, the account name returned should be + the entire distinguished name. + See the initialization of $accountName above. + #> + + $startScopeIndex = $separatorIndex + $distinguishedNamePrefix.Length + $endScopeIndex = $MemberName.IndexOf(',', $startScopeIndex) + + if ($endScopeIndex -gt $startScopeIndex) + { + $scopeLength = $endScopeIndex - $separatorIndex - $distinguishedNamePrefix.Length + $scope = $MemberName.Substring($startScopeIndex, $scopeLength) + + return [System.String[]] @( $scope, $accountName ) + } + } + + return [System.String[]] @( $scope, $accountName ) +} + +function Find-Principal +{ + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [System.DirectoryServices.AccountManagement.PrincipalContext] + $PrincipalContext, + + [Parameter(Mandatory = $true)] + [String] + $IdentityValue, + + [System.DirectoryServices.AccountManagement.IdentityType] + $IdentityType + ) + + if ($PSBoundParameters.ContainsKey('IdentityType')) + { + return [System.DirectoryServices.AccountManagement.Principal]::FindByIdentity($PrincipalContext, $IdentityType, $IdentityValue) + } + else + { + return [System.DirectoryServices.AccountManagement.Principal]::FindByIdentity($PrincipalContext, $IdentityValue) + } + +} + +<# + .SYNOPSIS + Retrieves a local Windows group. + + .PARAMETER GroupName + The name of the group to retrieve. + + .PARAMETER Disposables + The ArrayList of disposable objects to which to add any objects that need to be disposed. + + .PARAMETER PrincipalContextCache + A hashtable cache of PrincipalContext instances for each scope. + This is used to cache PrincipalContext instances for cases where it is used multiple times. + + .NOTES + The returned value is NOT added to the $disposables list because the caller may need to + call $group.Delete() which also disposes it. +#> +function Get-Group +{ + [OutputType([System.DirectoryServices.AccountManagement.GroupPrincipal])] + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] + $GroupName, + + [Parameter(Mandatory = $true)] + [System.DirectoryServices.AccountManagement.PrincipalContext] + $PrincipalContext + ) + + $principalContext = Get-PrincipalContext ` + -PrincipalContextCache $PrincipalContextCache ` + -Disposables $Disposables ` + -Scope $env:COMPUTERNAME + + try + { + $group = [System.DirectoryServices.AccountManagement.GroupPrincipal]::FindByIdentity($PrincipalContext, $GroupName) + } + catch + { + $group = $null + } + + return $group +} + +function Get-GroupMembersFromDirectoryEntry +{ + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNull()] + [System.DirectoryServices.AccountManagement.GroupPrincipal] + $Group + ) + + $groupDirectoryEntry = $Group.GetUnderlyingObject() + return $groupDirectoryEntry.Invoke('Members') +} + +<# + .SYNOPSIS + Clears the members of the specified group. + This is a wrapper function for testing purposes. + + .PARAMETER Group + The group to clear the members of. +#> +function Clear-GroupMembers +{ + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNull()] + [System.DirectoryServices.AccountManagement.GroupPrincipal] + $Group + ) + + $Group.Members.Clear() +} + +<# + .SYNOPSIS + Adds the specified member to the specified group. + This is a wrapper function for testing purposes. + + .PARAMETER Group + The group to add the member to. + + .PARAMETER MemberAsPrincipal + The member to add to the group as a principal. +#> +function Add-GroupMember +{ + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNull()] + [System.DirectoryServices.AccountManagement.GroupPrincipal] + $Group, + + [Parameter(Mandatory = $true)] + [ValidateNotNull()] + [System.DirectoryServices.AccountManagement.Principal] + $MemberAsPrincipal + ) + + $Group.Members.Add($MemberAsPrincipal) +} + +<# + .SYNOPSIS + Removes the specified member from the specified group. + This is a wrapper function for testing purposes. + + .PARAMETER Group + The group to remove the member from. + + .PARAMETER MemberAsPrincipal + The member to remove from the group as a principal. +#> +function Remove-GroupMember +{ + [CmdletBinding(SupportsShouldProcess = $true)] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNull()] + [System.DirectoryServices.AccountManagement.GroupPrincipal] + $Group, + + [Parameter(Mandatory = $true)] + [ValidateNotNull()] + [System.DirectoryServices.AccountManagement.Principal] + $MemberAsPrincipal + ) + + $Group.Members.Remove($MemberAsPrincipal) +} + +<# + .SYNOPSIS + Deletes the specified group. + This is a wrapper function for testing purposes. + + .PARAMETER Group + The group to delete. +#> +function Remove-Group +{ + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNull()] + [System.DirectoryServices.AccountManagement.GroupPrincipal] + $Group + ) + + $Group.Delete() +} + +<# + .SYNOPSIS + Saves the specified group. + This is a wrapper function for testing purposes. + + .PARAMETER Group + The group to save. +#> +function Save-Group +{ + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNull()] + [System.DirectoryServices.AccountManagement.GroupPrincipal] + $Group + ) + + $Group.Save() +} + +<# + .SYNOPSIS + Disposes of the contents of an array list containing IDisposable objects. + + .PARAMETER Disosables + The array list of IDisposable Objects to dispose of. +#> +function Remove-DisposableObject +{ + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNull()] + [System.Collections.ArrayList] + [AllowEmptyCollection()] + $Disposables + ) + + foreach ($disposable in $Disposables) + { + if ($disposable -is [System.IDisposable]) + { + $disposable.Dispose() + } + } +} + +Export-ModuleMember -Function '*-TargetResource' diff --git a/DscResources/MSFT_GroupResource/MSFT_GroupResource.schema.mof b/DscResources/MSFT_GroupResource/MSFT_GroupResource.schema.mof new file mode 100644 index 0000000..1963726 --- /dev/null +++ b/DscResources/MSFT_GroupResource/MSFT_GroupResource.schema.mof @@ -0,0 +1,12 @@ + +[ClassVersion("1.0.0"),FriendlyName("Group")] +class MSFT_GroupResource : OMI_BaseResource +{ + [Key, Description("The name of the group to create, modify, or remove.")] String GroupName; + [Write, ValueMap{"Present", "Absent"}, Values{"Present", "Absent"}, Description("Indicates if the group should exist or not.")] String Ensure; + [Write, Description("The description the group should have.")] String Description; + [Write, Description("The members the group should have.")] String Members[]; + [Write, Description("The members the group should include.")] String MembersToInclude[]; + [Write, Description("The members the group should exclude.")] String MembersToExclude[]; + [Write, EmbeddedInstance("MSFT_Credential"), Description("A credential to resolve non-local group members.")] String Credential; +}; diff --git a/DscResources/MSFT_GroupResource/en-US/MSFT_GroupResource.schema.mfl b/DscResources/MSFT_GroupResource/en-US/MSFT_GroupResource.schema.mfl new file mode 100644 index 0000000..6122b8d Binary files /dev/null and b/DscResources/MSFT_GroupResource/en-US/MSFT_GroupResource.schema.mfl differ diff --git a/DscResources/MSFT_GroupResource/en-US/MSFT_GroupResource.strings.psd1 b/DscResources/MSFT_GroupResource/en-US/MSFT_GroupResource.strings.psd1 new file mode 100644 index 0000000..fd36259 --- /dev/null +++ b/DscResources/MSFT_GroupResource/en-US/MSFT_GroupResource.strings.psd1 @@ -0,0 +1,36 @@ +# Localized resources for MSFT_GroupResource + +ConvertFrom-StringData @' + GroupWithName = Group: {0} + RemoveOperation = Remove + AddOperation = Add + SetOperation = Set + GroupCreated = Group {0} created successfully. + GroupUpdated = Group {0} properties updated successfully. + GroupRemoved = Group {0} removed successfully. + NoConfigurationRequired = Group {0} exists on this node with the desired properties. No action required. + NoConfigurationRequiredGroupDoesNotExist = Group {0} does not exist on this node. No action required. + CouldNotFindPrincipal = Could not find a principal with the provided name {0}. + MembersAndIncludeExcludeConflict = The {0} and {1} parameters conflict. The {0} parameter should not be used in any combination with the {1} parameter. + GroupAndMembersEmpty = Members is empty and group {0} has no members. No change to group members is needed. + MemberIsNotALocalUser = {0} is not a local user. User's principal source is {1}. + MemberNotValid = The group member {0} does not exist or cannot be resolved. + IncludeAndExcludeConflict = The principal {0} is included in both {1} and {2} parameter values. The same principal cannot be included in both {1} and {2} parameter values. + InvalidGroupName = The group name {0} cannot be used. Names may not consist entirely of periods and/or whitespace or contain these characters: {1} + GroupExists = A group with the name {0} exists. + GroupDoesNotExist = A group with the name {0} does not exist. + PropertyMismatch = The value of the {0} property is expected to be {1} but it is {2}. + MembersNumberMismatch = The number of provided unique group members {1} in {0} is different from the number of actual group members {2}. + MembersMemberMismatch = At least one member {0} of the provided {1} parameter does not match a user in the existing group {2}. + MemberToExcludeMatch = At least one member {0} of the provided {1} parameter matches a user in the existing group {2}. + ResolvingLocalAccount = Resolving {0} as a local account. + ResolvingDomainAccount = Resolving {0} in the domain {1}. + ResolvingDomainAccountWithTrust = Resolving {0} with domain trust. + DomainCredentialsRequired = Credentials are required to resolve the domain account {0}. + UnableToResolveAccount = Unable to resolve account '{0}'. Failed with message: {1} (error code={2}) + InvokingFunctionForGroup = Invoking the function {0} for the group {1}. + SetTargetResourceStartMessage = Begin executing Set functionality on the group {0}. + SetTargetResourceEndMessage = End executing Set functionality on the group {0}. + MembersToIncludeEmpty = MembersToInclude is empty. No group member additions are needed. + MembersToExcludeEmpty = MembersToExclude is empty. No group member removals are needed. +'@ diff --git a/Examples/Sample_Group_Members.ps1 b/Examples/Sample_Group_Members.ps1 new file mode 100644 index 0000000..f13ee0b --- /dev/null +++ b/Examples/Sample_Group_Members.ps1 @@ -0,0 +1,36 @@ +<# + .SYNOPSIS + Creates a group with the specified name and members or modifies the members of a group if + the named group already exists. + + .PARAMETER GroupName + The name of the group to create or modify. + + .PARAMETER Members + The list of members the group should have. + The default value is an empty list which will remove all members from the group. +#> +Configuration Sample_Group +{ + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] + $GroupName, + + [String[]] + $Members = @() + ) + + Import-DscResource -ModuleName 'PSDscResources' + + Group Group1 + { + GroupName = $GroupName + Ensure = 'Present' + Members = $Members + } +} + +Sample_Group diff --git a/Examples/Sample_Group_MembersToIncludeExclude.ps1 b/Examples/Sample_Group_MembersToIncludeExclude.ps1 new file mode 100644 index 0000000..f21e981 --- /dev/null +++ b/Examples/Sample_Group_MembersToIncludeExclude.ps1 @@ -0,0 +1,44 @@ +<# + .SYNOPSIS + Creates a group with the specified name and members included or modifies the members of a group if + the named group already exists. + + .PARAMETER GroupName + The name of the group to create or modify. + + .PARAMETER MembersToInclude + The list of members the group should contain. + The default value is an empty list. + + .PARAMETER MembersToExclude + The list of members the group should not contain. + The default value is an empty list. +#> +Configuration Sample_Group +{ + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] + $GroupName, + + [String[]] + $MembersToInclude = @(), + + [String[]] + $MembersToExclude = @() + ) + + Import-DscResource -ModuleName 'PSDscResources' + + Group Group1 + { + GroupName = $GroupName + Ensure = 'Present' + MembersToInclude = $MembersToInclude + MembersToExclude = $MembersToExclude + } +} + +Sample_Group diff --git a/PSDscResources.psd1 b/PSDscResources.psd1 index 743d98c..28d829f 100644 --- a/PSDscResources.psd1 +++ b/PSDscResources.psd1 @@ -73,7 +73,7 @@ VariablesToExport = '*' AliasesToExport = @() # DSC resources to export from this module -DscResourcesToExport = 'WindowsOptionalFeature' +DscResourcesToExport = 'Group', 'Service', 'User', 'WindowsOptionalFeature', 'WindowsPackageCab' # List of all modules packaged with this module # ModuleList = @() diff --git a/README.md b/README.md index 8e7857c..a9c83b2 100644 --- a/README.md +++ b/README.md @@ -2,21 +2,142 @@ PSDscResources is the new home of the in-box resources from PSDesiredStateConfiguration. +These resources are a combination of those in the in-box PSDesiredStateConfiguration module as well as community contributions from our experimental [xPSDesiredStateConfiguration](https://github.com/PowerShell/xPSDesiredStateConfiguration) module on GitHub. +These resources have also recently been updated to meet the DSC Resource Kit [High Quality Resource Module (HQRM) guidelines](https://github.com/PowerShell/DscResources/blob/master/HighQualityModuleGuidelines.md). + +In-box resources not currently included in this module should not be affected and can still load from the in-box PSDesiredStateConfiguration module. + +Because PSDscResources overwrites in-box resources, it is only available for WMF 5.1. +Many of the resource updates provided here are also included in the [xPSDesiredStateConfiguration](https://github.com/PowerShell/xPSDesiredStateConfiguration) module which is still compatible with WMF 4 and WMF 5 (though this module is not supported and may be removed in the future). + +To update your in-box resources to the newest versions provided by PSDscResources, first install PSDscResources from the PowerShell Gallery: +```powershell +Install-Module PSDscResources +``` + +Then, simply add this line to your DSC configuration: +```powershell +Import-DscResource -ModuleName PSDscResources +``` + This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. ## Contributing -Please check out common DSC Resources [contributing guidelines](https://github.com/PowerShell/DscResource.Kit/blob/master/CONTRIBUTING.md). +Please check out the common DSC Resources [contributing guidelines](https://github.com/PowerShell/DscResource.Kit/blob/master/CONTRIBUTING.md). ## Resources +* [Group](#group): Provides a mechanism to manage local groups on the target node. +* [Service](#service): Provides a mechanism to configure and manage Windows services. +* [User](#user): Provides a mechanism to manage local users on the target node. * [WindowsOptionalFeature](#windows-optional-feature): Provides a mechanism to enable or disable optional features on a target node. * [WindowsPackageCab](#windows-package-cab): Provides a mechanism to install or uninstall a package from a windows cabinet (cab) file on a target node. ### Resources that work on Nano Server +* [Group](#group) +* [Service](#service) +* [User](#user) * [WindowsOptionalFeature](#windows-optional-feature) * [WindowsPackageCab](#windows-package-cab) +### Group +Provides a mechanism to manage local groups on the target node. +This resource works on Nano Server. + +#### Requirements + +None + +#### Parameters +* **[String] GroupName** _(Key)_: The name of the group to create, modify, or remove. +* **[String] Ensure** _(Write)_: Indicates if the group should exist or not. To add a group or modify an existing group, set this property to Present. To remove a group, set this property to Absent. The default value is Present. { *Present* | Absent }. +* **[String] Description** _(Write)_: The description the group should have. +* **[String[]] Members** _(Write)_: The members the group should have. This property will replace all the current group members with the specified members. Members should be specified as strings in the format of their domain qualified name (domain\username), their UPN (username@domainname), their distinguished name (CN=username,DC=...), or their username (for local machine accounts). Using either the MembersToExclude or MembersToInclude properties in the same configuration as this property will generate an error. +* **[String[]] MembersToInclude** _(Write)_: The members the group should include. This property will only add members to a group. Members should be specified as strings in the format of their domain qualified name (domain\username), their UPN (username@domainname), their distinguished name (CN=username,DC=...), or their username (for local machine accounts). Using the Members property in the same configuration as this property will generate an error. +* **[String[]] MembersToExclude** _(Write)_: The members the group should exclude. This property will only remove members from a group. Members should be specified as strings in the format of their domain qualified name (domain\username), their UPN (username@domainname), their distinguished name (CN=username,DC=...), or their username (for local machine accounts). Using the Members property in the same configuration as this property will generate an error. +* **[System.Management.Automation.PSCredential] Credential** _(Write)_: A credential to resolve non-local group members. + +#### Read-Only Properties from Get-TargetResource + +None + +#### Examples + +* [Create or modify a group with Members](https://github.com/PowerShell/PSDscResources/blob/master/Examples/Sample_Group_Members.ps1) +* [Create or modify a group with MembersToInclude and/or MembersToExclude](https://github.com/PowerShell/PSDscResources/blob/master/Examples/Sample_Group_Members.ps1) + +### Service +Provides a mechanism to configure and manage Windows services. +This resource works on Nano Server. + +### Requirements + +None + +### Parameters + +* **[String] Name** _(Key)_: Indicates the service name. Note that sometimes this is different from the display name. You can get a list of the services and their current state with the Get-Service cmdlet. +* **[String] Ensure** _(Write)_: Indicates whether the service is present or absent. Defaults to Present. { *Present* | Absent }. +* **[String] Path** _(Write)_: The path to the service executable file. +* **[String] StartupType** _(Write)_: Indicates the startup type for the service. { Automatic | Disabled | Manual }. +* **[String] BuiltInAccount** _(Write)_: Indicates the sign-in account to use for the service. { LocalService | LocalSystem | NetworkService }. +* **[PSCredential] Credential** _(Write)_: The credential to run the service under. +* **[Boolean] DesktopInteract** _(Write)_: Indicates whether the service can create or communicate with a window on the desktop. Must be false for services not running as LocalSystem. Defaults to False. +* **[String] State** _(Write)_: Indicates the state you want to ensure for the service. Defaults to Running. { *Running* | Stopped | Ignore }. +* **[String] DisplayName** _(Write)_: The display name of the service. +* **[String] Description** _(Write)_: The description of the service. +* **[String[]] Dependencies** _(Write)_: An array of strings indicating the names of the dependencies of the service. +* **[Uint32] StartupTimeout** _(Write)_: The time to wait for the service to start in milliseconds. Defaults to 30000. +* **[Uint32] TerminateTimeout** _(Write)_: The time to wait for the service to stop in milliseconds. Defaults to 30000. + +#### Read-Only Properties from Get-TargetResource + +None + +### Examples + +* [Create a service](https://github.com/PowerShell/PSDscResources/blob/master/Examples/Sample_Service_CreateService.ps1) +* [Delete a service](https://github.com/PowerShell/PSDscResources/blob/master/Examples/Sample_Service_DeleteService.ps1) + +### User +Provides a mechanism to manage local users on a target node. + +#### Requirements + +None + +#### Parameters + +* **[String] UserName** _(Key)_: Indicates the account name for which you want to ensure a specific state. +* **[String] Description** _(Write)_: Indicates the description you want to use for the user account. +* **[Boolean] Disabled** _(Write)_: Indicates if the account is enabled. Set this property to $true to ensure that this account is disabled, and set it to $false to ensure that it is enabled. + - Suported values: $true, $false + - Default value: $false +* **[String] Ensure** _(Write)_: Ensures that the feature is present or absent. + - Supported values: Present, Absent + - Default Value: Present +* **[String] FullName** _(Write)_: Represents a string with the full name you want to use for the user account. +* **[PSCredential] Password** _(Write)_: Indicates the password you want to use for this account. +* **[Boolean] PasswordChangeNotAllowed** _(Write)_: Indicates if the user can change the password. Set this property to $true to ensure that the user cannot change the password, and set it to $false to allow the user to change the password. + - Suported values: $true, $false + - Default value: $false +* **[Boolean] PasswordChangeRequired** _(Write)_: Indicates if the user must change the password at the next sign in. Set this property to $true if the user must change the password. + - Suported values: $true, $false + - Default value: $true +* **[Boolean] PasswordNeverExpires** _(Write)_: Indicates if the password will expire. To ensure that the password for this account will never expire, set this property to $true, and set it to $false if the password will expire. + - Suported values: $true, $false + - Default value: $false + +#### Read-Only Properties from Get-TargetResource + +None + +#### Examples + +* [Create a new User](https://github.com/PowerShell/PSDscResources/blob/master/Examples/Sample_User_CreateUser.ps1) + + ### WindowsOptionalFeature Provides a mechanism to enable or disable optional features on a target node. This resource works on Nano Server. @@ -66,7 +187,7 @@ None #### Examples -* [Install a cab file with the given name from the given path](https://github.com/PowerShell/xPSDesiredStateConfiguration/blob/dev/Examples/Sample_xWindowsPackageCab.ps1) +* [Install a cab file with the given name from the given path](https://github.com/PowerShell/PSDesResources/blob/master/Examples/Sample_WindowsPackageCab.ps1) ### Unreleased diff --git a/Tests/Integration/MSFT_GroupResource.Integration.Tests.ps1 b/Tests/Integration/MSFT_GroupResource.Integration.Tests.ps1 new file mode 100644 index 0000000..53d68dc --- /dev/null +++ b/Tests/Integration/MSFT_GroupResource.Integration.Tests.ps1 @@ -0,0 +1,199 @@ +[Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingConvertToSecureStringWithPlainText", "")] +param () + +if ($PSVersionTable.PSVersion.Major -lt 5 -or $PSVersionTable.PSVersion.Minor -lt 1) +{ + Write-Warning -Message 'Cannot run PSDscResources integration tests on PowerShell versions lower than 5.1' + return +} + +Import-Module -Name (Join-Path -Path (Join-Path -Path (Split-Path $PSScriptRoot -Parent) -ChildPath 'TestHelpers') -ChildPath 'CommonTestHelper.psm1') + +$script:testEnvironment = Enter-DscResourceTestEnvironment ` + -DscResourceModuleName 'PSDscResources' ` + -DscResourceName 'MSFT_GroupResource' ` + -TestType 'Integration' + +try +{ + Describe 'Group Integration Tests' { + BeforeAll { + Import-Module -Name (Join-Path -Path (Join-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -ChildPath 'TestHelpers') -ChildPath 'MSFT_GroupResource.TestHelper.psm1') + + $script:confgurationWithMembersFilePath = Join-Path -Path $PSScriptRoot -ChildPath 'MSFT_GroupResource_Members.config.ps1' + $script:confgurationWithMembersToIncludeExcludeFilePath = Join-Path -Path $PSScriptRoot -ChildPath 'MSFT_GroupResource_MembersToIncludeExclude.config.ps1' + } + + It 'Should create an empty group' { + $configurationName = 'CreateEmptyGroup' + $testGroupName = 'TestEmptyGroup1' + + $resourceParameters = @{ + Ensure = 'Present' + GroupName = $testGroupName + } + + Test-GroupExists -GroupName $testGroupName | Should Be $false + + try + { + { + . $script:confgurationWithMembersFilePath -ConfigurationName $configurationName + & $configurationName -OutputPath $TestDrive @resourceParameters + Start-DscConfiguration -Path $TestDrive -ErrorAction 'Stop' -Wait -Force + } | Should Not Throw + + Test-GroupExists -GroupName $testGroupName | Should Be $true + } + finally + { + if (Test-GroupExists -GroupName $testGroupName) + { + Remove-Group -GroupName $testGroupName + } + } + } + + It 'Should create a group with two test users using Members' { + $configurationName = 'CreateGroupWithTwoMembers' + $testGroupName = 'TestGroupWithMembers2' + + $username1 = 'TestUser1' + $username2 = 'TestUser2' + + $testPassword = 'T3stPassw0rd#' + $secureTestPassword = ConvertTo-SecureString -String $testPassword -AsPlainText -Force + + $testUserCredential1 = New-Object -TypeName 'System.Management.Automation.PSCredential' -ArgumentList @( $username1, $secureTestPassword ) + $testUserCredential2 = New-Object -TypeName 'System.Management.Automation.PSCredential' -ArgumentList @( $username2, $secureTestPassword ) + + $user1 = New-User -Credential $testUserCredential1 + $user2 = New-User -Credential $testUserCredential2 + + $resourceParameters = @{ + Ensure = 'Present' + GroupName = $testGroupName + Members = @( $username1, $username2 ) + } + + Test-GroupExists -GroupName $testGroupName | Should Be $false + + try + { + { + . $script:confgurationWithMembersFilePath -ConfigurationName $configurationName + & $configurationName -OutputPath $TestDrive @resourceParameters + Start-DscConfiguration -Path $TestDrive -ErrorAction 'Stop' -Wait -Force + } | Should Not Throw + + Test-GroupExists -GroupName $testGroupName | Should Be $true + } + finally + { + if (Test-GroupExists -GroupName $testGroupName) + { + Remove-Group -GroupName $testGroupName + } + + Remove-User -UserName $username1 + Remove-User -UserName $username2 + } + } + + It 'Should add a member to a group with MembersToInclude' { + $configurationName = 'CreateGroupWithTwoMembers' + $testGroupName = 'TestGroupWithMembersToInclude3' + + $username1 = 'TestUser1' + + $testPassword = 'T3stPassw0rd#' + $secureTestPassword = ConvertTo-SecureString -String $testPassword -AsPlainText -Force + + $testUserCredential1 = New-Object -TypeName 'System.Management.Automation.PSCredential' -ArgumentList @( $username1, $secureTestPassword ) + + $user1 = New-User -Credential $testUserCredential1 + + $resourceParameters = @{ + Ensure = 'Present' + GroupName = $testGroupName + MembersToInclude = @( $username1 ) + } + + Test-GroupExists -GroupName $testGroupName | Should Be $false + + New-Group -GroupName $testGroupName + + Test-GroupExists -GroupName $testGroupName | Should Be $true + + try + { + { + . $script:confgurationWithMembersToIncludeExcludeFilePath -ConfigurationName $configurationName + & $configurationName -OutputPath $TestDrive @resourceParameters + Start-DscConfiguration -Path $TestDrive -ErrorAction 'Stop' -Wait -Force + } | Should Not Throw + + Test-GroupExists -GroupName $testGroupName | Should Be $true + } + finally + { + if (Test-GroupExists -GroupName $testGroupName) + { + Remove-Group -GroupName $testGroupName + } + + Remove-User -UserName $username1 + } + } + + It 'Should remove a member from a group with MembersToExclude' { + $configurationName = 'CreateGroupWithTwoMembers' + $testGroupName = 'TestGroupWithMembersToInclude3' + + $username1 = 'TestUser1' + + $testPassword = 'T3stPassw0rd#' + $secureTestPassword = ConvertTo-SecureString -String $testPassword -AsPlainText -Force + + $testUserCredential1 = New-Object -TypeName 'System.Management.Automation.PSCredential' -ArgumentList @( $username1, $secureTestPassword ) + + $user1 = New-User -Credential $testUserCredential1 + + $resourceParameters = @{ + Ensure = 'Present' + GroupName = $testGroupName + MembersToExclude = @( $username1 ) + } + + Test-GroupExists -GroupName $testGroupName | Should Be $false + + New-Group -GroupName $testGroupName -MemberUserNames @( $username1 ) + + Test-GroupExists -GroupName $testGroupName | Should Be $true + + try + { + { + . $script:confgurationWithMembersToIncludeExcludeFilePath -ConfigurationName $configurationName + & $configurationName -OutputPath $TestDrive @resourceParameters + Start-DscConfiguration -Path $TestDrive -ErrorAction 'Stop' -Wait -Force + } | Should Not Throw + + Test-GroupExists -GroupName $testGroupName | Should Be $true + } + finally + { + if (Test-GroupExists -GroupName $testGroupName) + { + Remove-Group -GroupName $testGroupName + } + + Remove-User -UserName $username1 + } + } + } +} +finally +{ + Exit-DscResourceTestEnvironment -TestEnvironment $script:testEnvironment +} diff --git a/Tests/Integration/MSFT_GroupResource_Members.config.ps1 b/Tests/Integration/MSFT_GroupResource_Members.config.ps1 new file mode 100644 index 0000000..681d127 --- /dev/null +++ b/Tests/Integration/MSFT_GroupResource_Members.config.ps1 @@ -0,0 +1,34 @@ +param +( + [Parameter(Mandatory = $true)] + [String] + $ConfigurationName +) + +Configuration $ConfigurationName +{ + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] + $GroupName, + + [ValidateSet('Present', 'Absent')] + [ValidateNotNullOrEmpty()] + [String] + $Ensure = 'Present', + + [String[]] + $Members = @() + ) + + Import-DscResource -ModuleName 'PSDscResources' + + Group Group1 + { + GroupName = $GroupName + Ensure = $Ensure + Members = $Members + } +} diff --git a/Tests/Integration/MSFT_GroupResource_MembersToIncludeExclude.config.ps1 b/Tests/Integration/MSFT_GroupResource_MembersToIncludeExclude.config.ps1 new file mode 100644 index 0000000..0052e08 --- /dev/null +++ b/Tests/Integration/MSFT_GroupResource_MembersToIncludeExclude.config.ps1 @@ -0,0 +1,38 @@ +param +( + [Parameter(Mandatory = $true)] + [String] + $ConfigurationName +) + +Configuration $ConfigurationName +{ + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] + $GroupName, + + [ValidateSet('Present', 'Absent')] + [ValidateNotNullOrEmpty()] + [String] + $Ensure = 'Present', + + [String[]] + $MembersToInclude = @(), + + [String[]] + $MembersToExclude = @() + ) + + Import-DscResource -ModuleName 'PSDscResources' + + Group Group2 + { + GroupName = $GroupName + Ensure = $Ensure + MembersToInclude = $MembersToInclude + MembersToExclude = $MembersToExclude + } +} diff --git a/Tests/TestHelpers/MSFT_GroupResource.TestHelper.psm1 b/Tests/TestHelpers/MSFT_GroupResource.TestHelper.psm1 new file mode 100644 index 0000000..95b01e9 --- /dev/null +++ b/Tests/TestHelpers/MSFT_GroupResource.TestHelper.psm1 @@ -0,0 +1,486 @@ +Import-Module -Name (Join-Path -Path (Join-Path -Path (Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent) -ChildPath 'DSCResources') -ChildPath 'CommonResourceHelper.psm1') + +<# + .SYNOPSIS + Determines if a Windows group exists. + + .DESCRIPTION + This function determines if a Windows group exists on a local or remote machine. + + .PARAMETER GroupName + The name of the group to test. + + .PARAMETER ComputerName + The optional name of the computer to check. + The default value is the local machine. + + .NOTES + For remote machines, the currently logged on user must have rights to enumerate groups. +#> +function Test-GroupExists +{ + [OutputType([Boolean])] + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [String] + $GroupName, + + [ValidateNotNullOrEmpty()] + [String] + $ComputerName = $env:computerName + ) + + if (Test-IsNanoServer) + { + return Test-GroupExistsOnNanoServer @PSBoundParameters + } + else + { + return Test-GroupExistsOnFullSKU @PSBoundParameters + } + + if (Test-IsNanoServer) + { + # Try to find a group by its name. + try + { + $null = Get-LocalGroup -Name $GroupName -ErrorAction Stop + return $true + } + catch [System.Exception] + { + if ($_.CategoryInfo.ToString().Contains('GroupNotFoundException')) + { + # A group with the provided name does not exist. + return $false + } + throw $_.Exception + } + } + else + { + return [ADSI]::Exists("WinNT://$env:ComputerName/$GroupName,group") + } +} + +<# + .SYNOPSIS + Determines if a Windows group exists. + + .DESCRIPTION + This function determines if a Windows group exists on a local or remote machine. + + .PARAMETER GroupName + The name of the group to test. + + .PARAMETER ComputerName + The optional name of the computer to check. Omit to check for the group on the local machine. + + .NOTES + For remote machines, the currently logged on user must have rights to enumerate groups. +#> +function Test-GroupExistsOnFullSKU +{ + [OutputType([Boolean])] + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] + $GroupName, + + [ValidateNotNullOrEmpty()] + [String] + $ComputerName = $env:computerName + ) + + Set-StrictMode -Version 'Latest' + + $adsiComputerEntry = [ADSI] "WinNT://$ComputerName" + + foreach ($adsiComputerEntryChild in $adsiComputerEntry.Children) + { + if ($adsiComputerEntryChild.Path -like "WinNT://*$ComputerName/$GroupName") + { + return $true + } + } + + return $false +} + +<# + .SYNOPSIS + Determines if a Windows group exists. + + .DESCRIPTION + This function determines if a Windows group exists on a local or remote machine. + + .PARAMETER GroupName + The name of the group to test. + + .PARAMETER ComputerName + This parameter should not be used on NanoServer. +#> +function Test-GroupExistsOnNanoServer +{ + [OutputType([Boolean])] + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] + $GroupName, + + [ValidateNotNullOrEmpty()] + [String] + $ComputerName = $env:computerName + ) + + Set-StrictMode -Version 'Latest' + + if ($PSBoundParameters.ContainsKey('ComputerName')) + { + if (-not (Test-IsLocalMachine -Scope $ComputerName)) + { + throw 'Do not specify ComputerName when running on NanoServer unless it is the local machine.' + } + } + + try + { + Get-LocalGroup -Name $GroupName -ErrorAction Stop | Out-Null + return $true + } + catch [System.Exception] + { + if ($_.CategoryInfo.ToString().Contains('GroupNotFoundException')) + { + return $false + } + else + { + throw $_.Exception + } + } + + return $false +} + +<# + .SYNOPSIS + Creates a Windows group + + .DESCRIPTION + This function creates a Windows group on the local or remote machine. + + .PARAMETER GroupName + The name of the group to create + + .PARAMETER Description + The optional description to set for the group. + + .PARAMETER MemberUserNames + The usernames of the optional members to add to the group. + + .PARAMETER ComputerName + The optional name of the computer to update. Omit to create the group on the local machine. + + .NOTES + For remote machines, the currently logged on user must have rights to create a group. +#> +function New-Group +{ + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] + $GroupName, + + [String] + $Description, + + [String[]] + $MemberUserNames, + + [ValidateNotNullOrEmpty()] + [String] + $ComputerName = $env:computerName + ) + + Set-StrictMode -Version 'Latest' + + if (Test-IsNanoServer) + { + New-GroupOnNanoServer @PSBoundParameters + } + else + { + New-GroupOnFullSKU @PSBoundParameters + } +} + +<# + .SYNOPSIS + Creates a Windows group on a full server + + .DESCRIPTION + This function creates a Windows group on the local or remote full server machine. + + .PARAMETER GroupName + The name of the group to create + + .PARAMETER Description + The optional description to set for the group. + + .PARAMETER MemberUserNames + The usernames of the optional members to add to the group. + + .PARAMETER ComputerName + The optional name of the computer to update. Omit to create the group on the local machine. + + .NOTES + For remote machines, the currently logged on user must have rights to create a group. +#> +function New-GroupOnFullSKU +{ + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] + $GroupName, + + [String] + $Description, + + [String[]] + $MemberUserNames, + + [ValidateNotNullOrEmpty()] + [String] + $ComputerName = $env:computerName + ) + + Set-StrictMode -Version 'Latest' + + $adsiComputerEntry = [ADSI] "WinNT://$ComputerName" + + if (Test-GroupExists -GroupName $GroupName) + { + Remove-Group -GroupName $GroupName -ComputerName $ComputerName + } + + $adsiGroupEntry = $adsiComputerEntry.Create('Group', $GroupName) + + if ($PSBoundParameters.ContainsKey('Description')) + { + $adsiGroupEntry.Put('Description', $Description) | Out-Null + } + + $adsiGroupEntry.SetInfo() | Out-Null + + if ($PSBoundParameters.ContainsKey("MemberUserNames")) + { + $adsiGroupEntry = [ADSI]"WinNT://$ComputerName/$GroupName,group" + + foreach ($memberUserName in $MemberUserNames) + { + $adsiGroupEntry.Add("WinNT://$ComputerName/$memberUserName") | Out-Null + } + } +} + +<# + .SYNOPSIS + Creates a Windows group on a Nano server + + .DESCRIPTION + This function creates a Windows group on the local Nano server machine. + + .PARAMETER GroupName + The name of the group to create + + .PARAMETER Description + The optional description to set for the group. + + .PARAMETER MemberUserNames + The usernames of the optional members to add to the group. + + .PARAMETER ComputerName + This parameter should not be used on a Nano server. +#> +function New-GroupOnNanoServer +{ + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] + $GroupName, + + [String] + $Description, + + [String[]] + $MemberUserNames, + + [ValidateNotNullOrEmpty()] + [String] + $ComputerName = $env:computerName + ) + + Set-StrictMode -Version 'Latest' + + if ($PSBoundParameters.ContainsKey('ComputerName')) + { + if (-not (Test-IsLocalMachine -Scope $ComputerName)) + { + throw 'Do not specify ComputerName when running on NanoServer unless it is the local machine.' + } + } + + if (Test-GroupExists -GroupName $GroupName) + { + Remove-LocalGroup -Name $GroupName -ErrorAction SilentlyContinue + } + + New-LocalGroup -Name $GroupName + + if ($PSBoundParameters.ContainsKey('Description')) + { + Set-LocalGroup -Name $GroupName -Description $Description + } + + if ($PSBoundParameters.ContainsKey('MemberUserNames')) + { + Add-LocalGroupMember -Name $GroupName -Member $Members + } +} + +<# + .SYNOPSIS + Deletes a user group. + + .PARAMETER GroupName + The name of the user group to delete. + + .PARAMETER ComputerName + The optional name of the computer to update. + The default value is the local machine. +#> +function Remove-Group +{ + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] + $GroupName, + + [ValidateNotNullOrEmpty()] + [String] + $ComputerName = $env:computerName + ) + + if (Test-IsNanoServer) + { + Remove-GroupOnNanoServer @PSBoundParameters + } + else + { + Remove-GroupOnFullSKU @PSBoundParameters + } +} + +<# + .SYNOPSIS + Deletes a local user group on a full server. + + .PARAMETER GroupName + The name of the local user group to delete. + + .PARAMETER ComputerName + The optional name of the computer to update. + The default value is the local machine. +#> +function Remove-GroupOnFullSKU +{ + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] + $GroupName, + + [ValidateNotNullOrEmpty()] + [String] + $ComputerName = $env:computerName + ) + + Set-StrictMode -Version 'Latest' + + $adsiComputerEntry = [ADSI]"WinNT://$ComputerName" + + if (Test-GroupExists -GroupName $GroupName) + { + $adsiComputerEntry.Delete('Group', $GroupName) | Out-Null + } +} + +<# + .SYNOPSIS + Deletes a local user group on a Nano server. + + .PARAMETER GroupName + The name of the local user group to delete. + + .PARAMETER ComputerName + This parameter should not be used on NanoServer. + The default value is the local machine. +#> +function Remove-GroupOnNanoServer +{ + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] + $GroupName, + + [ValidateNotNullOrEmpty()] + [String] + $ComputerName = $env:computerName + ) + + Set-StrictMode -Version 'Latest' + + if ($PSBoundParameters.ContainsKey('ComputerName')) + { + if (-not (Test-IsLocalMachine -Scope $ComputerName)) + { + throw 'Do not specify ComputerName when running on NanoServer unless it is the local machine.' + } + } + + if (Test-GroupExists -GroupName $GroupName) + { + Remove-LocalGroup -Name $GroupName + } +} + +Export-ModuleMember -Function ` + New-Group, ` + Remove-Group, ` + Test-GroupExists diff --git a/Tests/Unit/MSFT_GroupResource.Tests.ps1 b/Tests/Unit/MSFT_GroupResource.Tests.ps1 new file mode 100644 index 0000000..5dacaea --- /dev/null +++ b/Tests/Unit/MSFT_GroupResource.Tests.ps1 @@ -0,0 +1,2007 @@ +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '')] +param () + +Import-Module -Name (Join-Path -Path (Join-Path -Path (Split-Path $PSScriptRoot -Parent) -ChildPath 'TestHelpers') -ChildPath 'CommonTestHelper.psm1') + +$script:testEnvironment = Enter-DscResourceTestEnvironment ` + -DscResourceModuleName 'PSDscResources' ` + -DscResourceName 'MSFT_GroupResource' ` + -TestType 'Unit' + +try +{ + InModuleScope 'MSFT_GroupResource' { + Describe 'Group Unit Tests' { + BeforeAll { + $script:disposableObjects = @() + + $script:testGroupName = 'TestGroup' + $script:testGroupDescription = 'A group for testing' + + $script:localDomain = $env:computerName + + $script:onNanoServer = Test-IsNanoServer + + $script:testMemberName1 = 'User1' + $script:testMemberName2 = 'User2' + $script:testMemberName3 = 'User3' + + $testUserName = 'TestUserName' + $testPassword = 'TestPassword' + $secureTestPassword = ConvertTo-SecureString -String $testPassword -AsPlainText -Force + + $script:testCredential = New-Object -TypeName 'System.Management.Automation.PSCredential' -ArgumentList @( $testUsername, $secureTestPassword ) + + $script:testErrorMessage = 'Test error message' + + if ($script:onNanoServer) + { + $script:testLocalGroup = New-Object -TypeName 'Microsoft.PowerShell.Commands.LocalGroup' -ArgumentList @( $script:testGroupName ) + } + else + { + $script:testPrincipalContext = New-Object -TypeName 'System.DirectoryServices.AccountManagement.PrincipalContext' -ArgumentList @( [System.DirectoryServices.AccountManagement.ContextType]::Machine ) + + $script:testGroup = New-Object -TypeName 'System.DirectoryServices.AccountManagement.GroupPrincipal' -ArgumentList @( $script:testPrincipalContext ) + $script:disposableObjects += $testGroup + + $script:testUserPrincipal1 = New-Object -TypeName 'System.DirectoryServices.AccountManagement.UserPrincipal' -ArgumentList @( $testPrincipalContext ) + $script:testuserPrincipal1.Name = $script:testMemberName1 + $script:testuserPrincipal1.SamAccountName = 'SamAccountName1' + $script:disposableObjects += $script:testuserPrincipal1 + + $script:testUserPrincipal2 = New-Object -TypeName 'System.DirectoryServices.AccountManagement.UserPrincipal' -ArgumentList @( $testPrincipalContext ) + $script:testuserPrincipal2.Name = $script:testMemberName2 + $script:testuserPrincipal2.SamAccountName = 'SamAccountName2' + $script:disposableObjects += $script:testuserPrincipal2 + + $script:testUserPrincipal3 = New-Object -TypeName 'System.DirectoryServices.AccountManagement.UserPrincipal' -ArgumentList @( $testPrincipalContext ) + $script:testuserPrincipal3.Name = $script:testMemberName3 + $script:testuserPrincipal3.SamAccountName = 'SamAccountName3' + $script:disposableObjects += $script:testuserPrincipal3 + } + } + + BeforeEach { + # Reset the test group + if (-not ($script:onNanoServer)) + { + $script:testGroup.Name = $script:testGroupName + $script:testGroup.Description = '' + + if ($script:testGroup.Members.Count -gt 0) + { + $script:testGroup.Members.Clear() + } + } + else + { + # Reset the local group + $script:testLocalGroup.Name = $script:testGroupName + $script:testLocalGroup.Description = '' + } + } + + AfterAll { + foreach ($disposableObject in $script:disposableObjects) + { + $disposableObject.Dispose() + } + } + + <# + Get-Group, Add-GroupMember, Remove-GroupMember, Clear-GroupMembers, Save-Group, + Remove-Group, Find-Principal, and Remove-DisposableObject cannot be unit tested + because they are wrapper functions for .NET class function calls. + #> + + Context 'Get-TargetResource' { + Mock -CommandName 'Assert-GroupNameValid' -MockWith { } + Mock -CommandName 'Test-IsNanoServer' -MockWith { return $false } + Mock -CommandName 'Get-TargetResourceOnFullSKU' -MockWith { return @{ TestResult = 'OnFullSKU' } } + Mock -CommandName 'Get-TargetResourceOnNanoServer' -MockWith { return @{ TestResult = 'OnNanoServer' } } + + It 'Should call Assert-GroupNameValid with the given group name' { + $null = Get-TargetResource -GroupName $script:testGroupName + Assert-MockCalled -CommandName 'Assert-GroupNameValid' -ParameterFilter { $GroupName -eq $script:testGroupName } + } + + It 'Should return output Get-TargetResourceOnFullSKU with all parameters when not on Nano Server' { + $getTargetResourceResult = Get-TargetResource -GroupName $script:testGroupName -Credential $script:testCredential + + Assert-MockCalled -CommandName 'Test-IsNanoServer' + Assert-MockCalled -CommandName 'Get-TargetResourceOnFullSKU' -ParameterFilter { $GroupName -eq $script:testGroupName -and $Credential -eq $script:testCredential } + $getTargetResourceResult.TestResult | Should Be 'OnFullSKU' + } + + It 'Should call Get-TargetResourceOnNanoServer with all parameters when on Nano Server' { + Mock -CommandName 'Test-IsNanoServer' -MockWith { return $true } + + $getTargetResourceResult = Get-TargetResource -GroupName $script:testGroupName -Credential $script:testCredential + + Assert-MockCalled -CommandName 'Test-IsNanoServer' + Assert-MockCalled -CommandName 'Get-TargetResourceOnNanoServer' -ParameterFilter { $GroupName -eq $script:testGroupName -and $Credential -eq $script:testCredential } + $getTargetResourceResult.TestResult | Should Be 'OnNanoServer' + } + } + + Context 'Set-TargetResource' { + Mock -CommandName 'Assert-GroupNameValid' -MockWith { } + Mock -CommandName 'Test-IsNanoServer' -MockWith { return $false } + Mock -CommandName 'Set-TargetResourceOnFullSKU' -MockWith { } + Mock -CommandName 'Set-TargetResourceOnNanoServer' -MockWith { } + + It 'Should call Assert-GroupNameValid with the given group name' { + $null = Set-TargetResource -GroupName $script:testGroupName + Assert-MockCalled -CommandName 'Assert-GroupNameValid' -ParameterFilter { $GroupName -eq $script:testGroupName } + } + + It 'Should call Set-TargetResourceOnFullSKU with all parameters when not on Nano Server' { + Set-TargetResource -GroupName $script:testGroupName -Credential $script:testCredential + + Assert-MockCalled -CommandName 'Test-IsNanoServer' + Assert-MockCalled -CommandName 'Set-TargetResourceOnFullSKU' -ParameterFilter { $GroupName -eq $script:testGroupName -and $Credential -eq $script:testCredential } + } + + It 'Should call Set-TargetResourceOnNanoServer with all parameters when on Nano Server' { + Mock -CommandName 'Test-IsNanoServer' -MockWith { return $true } + + Set-TargetResource -GroupName $script:testGroupName -Credential $script:testCredential + + Assert-MockCalled -CommandName 'Test-IsNanoServer' + Assert-MockCalled -CommandName 'Set-TargetResourceOnNanoServer' -ParameterFilter { $GroupName -eq $script:testGroupName -and $Credential -eq $script:testCredential } + } + } + + Context 'Test-TargetResource' { + Mock -CommandName 'Assert-GroupNameValid' -MockWith { } + Mock -CommandName 'Test-IsNanoServer' -MockWith { return $false } + Mock -CommandName 'Test-TargetResourceOnFullSKU' -MockWith { } + Mock -CommandName 'Test-TargetResourceOnNanoServer' -MockWith { } + + It 'Should call Assert-GroupNameValid with the given group name' { + $testTargetResourceResult = Test-TargetResource -GroupName $script:testGroupName + Assert-MockCalled -CommandName 'Assert-GroupNameValid' -ParameterFilter { $GroupName -eq $script:testGroupName } + } + + It 'Should call Test-TargetResourceOnFullSKU with all parameters when not on Nano Server' { + $testTargetResourceResult = Test-TargetResource -GroupName $script:testGroupName -Credential $script:testCredential + + Assert-MockCalled -CommandName 'Test-IsNanoServer' + Assert-MockCalled -CommandName 'Test-TargetResourceOnFullSKU' -ParameterFilter { $GroupName -eq $script:testGroupName -and $Credential -eq $script:testCredential } + } + + It 'Should call Test-TargetResourceOnNanoServer with all parameters when on Nano Server' { + Mock -CommandName 'Test-IsNanoServer' -MockWith { return $true } + + $testTargetResourceResult = Test-TargetResource -GroupName $script:testGroupName -Credential $script:testCredential + + Assert-MockCalled -CommandName 'Test-IsNanoServer' + Assert-MockCalled -CommandName 'Test-TargetResourceOnNanoServer' -ParameterFilter { $GroupName -eq $script:testGroupName -and $Credential -eq $script:testCredential } + } + } + + Context 'Assert-GroupNameValid' { + $invalidCharacters = @( '\', '/', '"', '[', ']', ':', '|', '<', '>', '+', '=', ';', ',', '?', '*', '@' ) + + foreach ($invalidCharacter in $invalidCharacters) + { + It "Should throw error if name contains invalid character '$invalidCharacter'" { + $invalidGroupName = ('Invalid' + $invalidCharacter + 'Group') + { Assert-GroupNameValid -GroupName $invalidGroupName } | Should Throw ($script:localizedData.InvalidGroupName -f $invalidGroupName, '') + } + } + + It 'Should throw if name contains only whitespace' { + $invalidGroupName = ' ' + { Assert-GroupNameValid -GroupName $invalidGroupName } | Should Throw ($script:localizedData.InvalidGroupName -f $invalidGroupName, '') + } + + It 'Should throw if name contains only dots' { + $invalidGroupName = '....' + { Assert-GroupNameValid -GroupName $invalidGroupName } | Should Throw ($script:localizedData.InvalidGroupName -f $invalidGroupName, '') + } + + It 'Should throw if name contains only whitespace and dots' { + $invalidGroupName = '.. ..' + { Assert-GroupNameValid -GroupName $invalidGroupName } | Should Throw ($script:localizedData.InvalidGroupName -f $invalidGroupName, '') + } + + It 'Should not throw if name contains whitespace and dots' { + $invalidGroupName = '.. MyGroup ..' + { Assert-GroupNameValid -GroupName $invalidGroupName } | Should Not Throw + } + } + + Context 'Test-IsLocalMachine' { + Mock -CommandName 'Get-CimInstance' -MockWith { } + + $localMachineScopes = @( '.', $env:computerName, 'localhost', '127.0.0.1' ) + + foreach ($localMachineScope in $localMachineScopes) + { + It "Should return true for local machine scope $localMachineScope" { + Test-IsLocalMachine -Scope $localMachineScope | Should Be $true + } + } + + $customLocalIPAddress = '123.4.5.6' + + It 'Should return false if custom local IP address provided and Get-CimInstance returns null' { + Test-IsLocalMachine -Scope $customLocalIPAddress | Should Be $false + } + + It 'Should return true if custom local IP address provided and Get-CimInstance contains matching IP address' { + Mock -CommandName 'Get-CimInstance' -MockWith { return @{ IPAddress = @($customLocalIPAddress, '789.1.2.3')} } + + Test-IsLocalMachine -Scope $customLocalIPAddress | Should Be $true + } + + It 'Should return false if custom local IP address provided and Get-CimInstance do not contain matching IP addresses' { + Mock -CommandName 'Get-CimInstance' -MockWith { return @{ IPAddress = @('789.1.2.3')} } + + Test-IsLocalMachine -Scope $customLocalIPAddress | Should Be $false + } + } + + Context 'Split-MemberName' { + Mock -CommandName 'Test-IsLocalMachine' -MockWith { return $true } + + It 'Should split a member name in the domain\username format with the machine domain' { + $testMemberName = 'domain\username' + $splitMemberNameResult = Split-MemberName -MemberName $testMemberName + + Assert-MockCalled -CommandName 'Test-IsLocalMachine' + + $splitMemberNameResult | Should Be @( $script:localDomain, 'username' ) + } + + Mock -CommandName 'Test-IsLocalMachine' -MockWith { return $false } + + It 'Should split a member name in the domain\username format with a custom domain' { + $testMemberName = 'domain\username' + $splitMemberNameResult = Split-MemberName -MemberName $testMemberName + + Assert-MockCalled -CommandName 'Test-IsLocalMachine' + + $splitMemberNameResult | Should Be @( 'domain', 'username' ) + } + + It 'Should split a member name in the username@domain format' { + $testMemberName = 'username@domain' + $splitMemberNameResult = Split-MemberName -MemberName $testMemberName + + $splitMemberNameResult | Should Be @( 'domain', 'username' ) + } + + It 'Should split a member name in the CN=username,DC=domain format with local domain' { + $testMemberName = 'CN=username,DC=domain' + $splitMemberNameResult = Split-MemberName -MemberName $testMemberName + + $splitMemberNameResult | Should Be @( $script:localDomain, $testMemberName ) + } + + It 'Should split a member name in the CN=username,DC=domain format with outisde domain' { + $testMemberName = 'CN=username,DC=domain,DC=com' + $splitMemberNameResult = Split-MemberName -MemberName $testMemberName + + $splitMemberNameResult | Should Be @( 'domain', $testMemberName ) + } + + It 'Should split a member name in the local username format' { + $testMemberName = 'username' + $splitMemberNameResult = Split-MemberName -MemberName $testMemberName + + $splitMemberNameResult | Should Be @( $script:localDomain, 'username' ) + } + } + + if ($script:onNanoServer) + { + Context 'Get-TargetResourceOnNanoServer' { + $testMembers = @('User1', 'User2') + + Mock -CommandName 'Get-MembersOnNanoServer' -MockWith { return @() } + + It 'Should return Ensure as Absent when Get-LocalGroup throws a GroupNotFound exception' { + Mock -CommandName 'Get-LocalGroup' -MockWith { Write-Error -Message 'Test error message' -CategoryReason 'GroupNotFoundException' } + + $getTargetResourceResult = Get-TargetResourceOnNanoServer -GroupName $script:testGroupName + + Assert-MockCalled -CommandName 'Get-LocalGroup' -ParameterFilter { $Name -eq $script:testGroupName } + + $getTargetResourceResult.GetType() | Should Be 'Hashtable' + $getTargetResourceResult.Keys.Count | Should Be 2 + $getTargetResourceResult.GroupName | Should Be $script:testGroupName + $getTargetResourceResult.Ensure | Should Be 'Absent' + } + + It 'Should throw an error when Get-LocalGroup throws an exception other than GroupNotFound' { + Mock -CommandName 'Get-LocalGroup' -MockWith { Write-Error -Message $script:testErrorMessage -CategoryReason 'OtherException' } + + { $getTargetResourceResult = Get-TargetResourceOnNanoServer -GroupName $script:testGroupName } | Should Throw $script:testErrorMessage + + Assert-MockCalled -CommandName 'Get-LocalGroup' -ParameterFilter { $Name -eq $script:testGroupName } + } + + It 'Should return correct hashtable values when Get-LocalGroup returns a valid, existing group without members' { + $script:testLocalGroup.Description = $script:testGroupDescription + + Mock -CommandName 'Get-LocalGroup' -MockWith { return $script:testLocalGroup } + + $getTargetResourceResult = Get-TargetResourceOnNanoServer -GroupName $script:testGroupName + + Assert-MockCalled -CommandName 'Get-LocalGroup' -ParameterFilter { $Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Get-MembersOnNanoServer' -ParameterFilter { $Group -eq $script:testLocalGroup } + + $getTargetResourceResult.GetType() | Should Be 'Hashtable' + $getTargetResourceResult.Keys.Count | Should Be 4 + $getTargetResourceResult.GroupName | Should Be $script:testGroupName + $getTargetResourceResult.Ensure | Should Be 'Present' + $getTargetResourceResult.Description | Should Be $script:testGroupDescription + $getTargetResourceResult.Members | Should Be $null + } + + It 'Should return correct hashtable values when Get-LocalGroup returns a valid, existing group with members' { + $script:testLocalGroup.Description = $script:testGroupDescription + + Mock -CommandName 'Get-LocalGroup' -MockWith { return $script:testLocalGroup } + Mock -CommandName 'Get-MembersOnNanoServer' -MockWith { return $testMembers } + + $getTargetResourceResult = Get-TargetResourceOnNanoServer -GroupName $script:testGroupName + + Assert-MockCalled -CommandName 'Get-LocalGroup' -ParameterFilter { $Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Get-MembersOnNanoServer' -ParameterFilter { $Group -eq $script:testLocalGroup } + + $getTargetResourceResult.GetType() | Should Be 'Hashtable' + $getTargetResourceResult.Keys.Count | Should Be 4 + $getTargetResourceResult.GroupName | Should Be $script:testGroupName + $getTargetResourceResult.Ensure | Should Be 'Present' + $getTargetResourceResult.Description | Should Be $script:testGroupDescription + $getTargetResourceResult.Members | Should Be $testMembers + } + } + + Context 'Set-TargetResourceOnNanoServer' { + Mock -CommandName 'Get-LocalGroup' -MockWith { Write-Error -Message 'Test error message' -CategoryReason 'GroupNotFoundException' } + Mock -CommandName 'New-LocalGroup' -MockWith { return $script:testLocalGroup } + Mock -CommandName 'Set-LocalGroup' -MockWith { } + Mock -CommandName 'Remove-LocalGroup' -MockWith { } + Mock -CommandName 'Get-MembersOnNanoServer' -MockWith { } + Mock -CommandName 'Add-LocalGroupMember' -MockWith { } + Mock -CommandName 'Remove-LocalGroupMember' -MockWith { } + + It 'Should not attempt to remove an absent group when Ensure is Absent' { + Set-TargetResourceOnNanoServer -GroupName $script:testGroupName -Ensure 'Absent' + + Assert-MockCalled -CommandName 'Get-LocalGroup' -ParameterFilter { $Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Remove-LocalGroup' -Times 0 -Scope 'It' + } + + It 'Should create an empty group' { + Set-TargetResourceOnNanoServer -GroupName $script:testGroupName -Ensure 'Present' + + Assert-MockCalled -CommandName 'Get-LocalGroup' -ParameterFilter { $Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'New-LocalGroup' -ParameterFilter { $Name -eq $script:testGroupName } + } + + It 'Should create an empty group with a description' { + Set-TargetResourceOnNanoServer -GroupName $script:testGroupName -Description $script:testGroupDescription -Ensure 'Present' + + Assert-MockCalled -CommandName 'Get-LocalGroup' -ParameterFilter { $Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'New-LocalGroup' -ParameterFilter { $Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Set-LocalGroup' -ParameterFilter { $Name -eq $script:testGroupName -and $Description -eq $script:testGroupDescription } + } + + It 'Should create a group with one local member using Members' { + $testMembers = @( $script:testMemberName1 ) + + Set-TargetResourceOnNanoServer -GroupName $script:testGroupName -Members $testMembers -Ensure 'Present' + + Assert-MockCalled -CommandName 'Get-LocalGroup' -ParameterFilter { $Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'New-LocalGroup' -ParameterFilter { $Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Get-MembersOnNanoServer' -ParameterFilter { $Group.Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Add-LocalGroupMember' -ParameterFilter { $Group.Name -eq $script:testGroupName -and $Member.Name -eq $script:testMemberName1 } + } + + It 'Should create a group with two local members using Members' { + $testMembers = @( $script:testMemberName1, $script:testMemberName2 ) + + Set-TargetResourceOnNanoServer -GroupName $script:testGroupName -Members $testMembers -Ensure 'Present' + + Assert-MockCalled -CommandName 'Get-LocalGroup' -ParameterFilter { $Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'New-LocalGroup' -ParameterFilter { $Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Get-MembersOnNanoServer' -ParameterFilter { $Group.Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Add-LocalGroupMember' -ParameterFilter { $Group.Name -eq $script:testGroupName -and $Member.Name -eq $script:testMemberName1 } + Assert-MockCalled -CommandName 'Add-LocalGroupMember' -ParameterFilter { $Group.Name -eq $script:testGroupName -and $Member.Name -eq $script:testMemberName2 } + } + + It 'Should create a group with one local member using MembersToInclude' { + $testMembers = @( $script:testMemberName1 ) + + Set-TargetResourceOnNanoServer -GroupName $script:testGroupName -MembersToInclude $testMembers -Ensure 'Present' + + Assert-MockCalled -CommandName 'Get-LocalGroup' -ParameterFilter { $Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'New-LocalGroup' -ParameterFilter { $Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Get-MembersOnNanoServer' -ParameterFilter { $Group.Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Add-LocalGroupMember' -ParameterFilter { $Group.Name -eq $script:testGroupName -and $Member.Name -eq $script:testMemberName1 } + } + + It 'Should create a group with two local members using MembersToInclude' { + $testMembers = @( $script:testMemberName1, $script:testMemberName2 ) + + Set-TargetResourceOnNanoServer -GroupName $script:testGroupName -MembersToInclude $testMembers -Ensure 'Present' + + Assert-MockCalled -CommandName 'Get-LocalGroup' -ParameterFilter { $Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'New-LocalGroup' -ParameterFilter { $Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Get-MembersOnNanoServer' -ParameterFilter { $Group.Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Add-LocalGroupMember' -ParameterFilter { $Group.Name -eq $script:testGroupName -and $Member.Name -eq $script:testMemberName1 } + Assert-MockCalled -CommandName 'Add-LocalGroupMember' -ParameterFilter { $Group.Name -eq $script:testGroupName -and $Member.Name -eq $script:testMemberName2 } + } + + Mock -CommandName 'Get-LocalGroup' -MockWith { Write-Error -Message $script:testErrorMessage -CategoryReason 'OtherException' } + + It 'Should throw from group retrieval if exception is not a GroupNotFoundException' { + { Set-TargetResourceOnNanoServer -GroupName $script:testGroupName -Ensure 'Present' } | Should Throw $script:testErrorMessage + } + + Mock -CommandName 'Get-LocalGroup' -MockWith { return $script:testLocalGroup } + + It 'Should add a member to an existing group with no members using Members' { + $testMembers = @( $script:testMemberName1 ) + + Set-TargetResourceOnNanoServer -GroupName $script:testGroupName -Members $testMembers -Ensure 'Present' + + Assert-MockCalled -CommandName 'Get-LocalGroup' -ParameterFilter { $Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Get-MembersOnNanoServer' -ParameterFilter { $Group.Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Add-LocalGroupMember' -ParameterFilter { $Group.Name -eq $script:testGroupName -and $Member.Name -eq $script:testMemberName1 } + } + + It 'Should add two members to an existing group with one of the members using Members' { + $testMembers = @( $script:testMemberName1, $script:testMemberName2 ) + + Set-TargetResourceOnNanoServer -GroupName $script:testGroupName -Members $testMembers -Ensure 'Present' + + Assert-MockCalled -CommandName 'Get-LocalGroup' -ParameterFilter { $Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Get-MembersOnNanoServer' -ParameterFilter { $Group.Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Add-LocalGroupMember' -ParameterFilter { $Group.Name -eq $script:testGroupName -and $Member.Name -eq $script:testMemberName1 } + Assert-MockCalled -CommandName 'Add-LocalGroupMember' -ParameterFilter { $Group.Name -eq $script:testGroupName -and $Member.Name -eq $script:testMemberName2 } + } + + It 'Should not modify group with no members if Members is empty' { + $testMembers = @( ) + + Set-TargetResourceOnNanoServer -GroupName $script:testGroupName -Members $testMembers -Ensure 'Present' + + Assert-MockCalled -CommandName 'Add-LocalGroupMember' -Times 0 -Scope 'It' + Assert-MockCalled -CommandName 'Remove-LocalGroupMember' -Times 0 -Scope 'It' + } + + It 'Should add a member to an existing group with no members using MembersToInclude' { + $testMembers = @( $script:testMemberName1 ) + + Set-TargetResourceOnNanoServer -GroupName $script:testGroupName -MembersToInclude $testMembers -Ensure 'Present' + + Assert-MockCalled -CommandName 'Get-LocalGroup' -ParameterFilter { $Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Get-MembersOnNanoServer' -ParameterFilter { $Group.Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Add-LocalGroupMember' -ParameterFilter { $Group.Name -eq $script:testGroupName -and $Member.Name -eq $script:testMemberName1 } + } + + It 'Should add two members to an existing group with one of the members using MembersToInclude' { + $testMembers = @( $script:testMemberName1, $script:testMemberName2 ) + + Set-TargetResourceOnNanoServer -GroupName $script:testGroupName -MembersToInclude $testMembers -Ensure 'Present' + + Assert-MockCalled -CommandName 'Get-LocalGroup' -ParameterFilter { $Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Get-MembersOnNanoServer' -ParameterFilter { $Group.Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Add-LocalGroupMember' -ParameterFilter { $Group.Name -eq $script:testGroupName -and $Member.Name -eq $script:testMemberName1 } + Assert-MockCalled -CommandName 'Add-LocalGroupMember' -ParameterFilter { $Group.Name -eq $script:testGroupName -and $Member.Name -eq $script:testMemberName2 } + } + + Mock -CommandName 'Get-MembersOnNanoServer' -MockWith { return @( $script:testMemberName1, $script:testMemberName2 ) } + + It 'Should remove a member from an existing group using Members' { + $testMembers = @( $script:testMemberName1 ) + + Set-TargetResourceOnNanoServer -GroupName $script:testGroupName -Members $testMembers -Ensure 'Present' + + Assert-MockCalled -CommandName 'Get-LocalGroup' -ParameterFilter { $Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Get-MembersOnNanoServer' -ParameterFilter { $Group.Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Remove-LocalGroupMember' -ParameterFilter { $Group.Name -eq $script:testGroupName -and $Member.Name -eq $script:testMemberName2 } + } + + It 'Should clear group members from an existing group using Members' { + $testMembers = @( ) + + Set-TargetResourceOnNanoServer -GroupName $script:testGroupName -Members $testMembers -Ensure 'Present' + + Assert-MockCalled -CommandName 'Get-LocalGroup' -ParameterFilter { $Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Get-MembersOnNanoServer' -ParameterFilter { $Group.Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Remove-LocalGroupMember' -ParameterFilter { $Group.Name -eq $script:testGroupName -and $Member.Name -eq $script:testMemberName1 } + Assert-MockCalled -CommandName 'Remove-LocalGroupMember' -ParameterFilter { $Group.Name -eq $script:testGroupName -and $Member.Name -eq $script:testMemberName2 } + } + + It 'Should remove a member from an existing group using MembersToExclude' { + $testMembers = @( $script:testMemberName2 ) + + Set-TargetResourceOnNanoServer -GroupName $script:testGroupName -MembersToExclude $testMembers -Ensure 'Present' + + Assert-MockCalled -CommandName 'Get-LocalGroup' -ParameterFilter { $Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Get-MembersOnNanoServer' -ParameterFilter { $Group.Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Remove-LocalGroupMember' -ParameterFilter { $Group.Name -eq $script:testGroupName -and $Member.Name -eq $script:testMemberName2 } + } + + It 'Should add a user and remove a user using Members' { + $testMembers = @( $script:testMemberName1, $script:testMemberName3 ) + + Set-TargetResourceOnNanoServer -GroupName $script:testGroupName -Members $testMembers -Ensure 'Present' + + Assert-MockCalled -CommandName 'Get-LocalGroup' -ParameterFilter { $Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Get-MembersOnNanoServer' -ParameterFilter { $Group.Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Add-LocalGroupMember' -ParameterFilter { $Group.Name -eq $script:testGroupName -and $Member.Name -eq $script:testMemberName3 } + Assert-MockCalled -CommandName 'Remove-LocalGroupMember' -ParameterFilter { $Group.Name -eq $script:testGroupName -and $Member.Name -eq $script:testMemberName2 } + } + + It 'Should add a user and remove a user using MembersToInclude and MembersToExclude at the same time' { + $testMembersToInclude = @( $script:testMemberName3 ) + $testMembersToExclude = @( $script:testMemberName2 ) + + Set-TargetResourceOnNanoServer -GroupName $script:testGroupName -MembersToInclude $testMemberstoInclude -MembersToExclude $testMemberstoExclude -Ensure 'Present' + + Assert-MockCalled -CommandName 'Get-LocalGroup' -ParameterFilter { $Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Get-MembersOnNanoServer' -ParameterFilter { $Group.Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Add-LocalGroupMember' -ParameterFilter { $Group.Name -eq $script:testGroupName -and $Member.Name -eq $script:testMemberName3 } + Assert-MockCalled -CommandName 'Remove-LocalGroupMember' -ParameterFilter { $Group.Name -eq $script:testGroupName -and $Member.Name -eq $script:testMemberName2 } + } + + It 'Should throw if Members and MembersToInclude are both specified' { + $testMembers = @( $script:testMemberName1, $script:testMemberName2 ) + $testMembersToInclude = @( $script:testMemberName3 ) + + $errorMessage = $script:localizedData.MembersAndIncludeExcludeConflict -f 'Members', 'MembersToInclude' + + { Set-TargetResourceOnNanoServer -GroupName $script:testGroupName -Members $testMembers -MembersToInclude $testMembersToInclude -Ensure 'Present' } | Should Throw $errorMessage + } + + It 'Should throw if Members and MembersToExclude are both specified' { + $testMembers = @( $script:testMemberName1, $script:testMemberName2 ) + $testMembersToExclude = @( $script:testMemberName3 ) + + $errorMessage = $script:localizedData.MembersAndIncludeExcludeConflict -f 'Members', 'MembersToExclude' + + { Set-TargetResourceOnNanoServer -GroupName $script:testGroupName -Members $testMembers -MembersToExclude $testMembersToExclude -Ensure 'Present' } | Should Throw $errorMessage + } + + It 'Should throw if MembersToInclude and MembersToExclude contain the same member' { + $testMembersToInclude = @( $script:testMemberName1 ) + $testMembersToExclude = @( $script:testMemberName1 ) + + $errorMessage = $script:localizedData.IncludeAndExcludeConflict -f $script:testMemberName1, 'MembersToInclude', 'MembersToExclude' + + { Set-TargetResourceOnNanoServer -GroupName $script:testGroupName -MembersToInclude $testMembersToInclude -MembersToExclude $testMembersToExclude -Ensure 'Present' } | Should Throw $errorMessage + } + + It 'Should not modify group if member specified by MembersToInclude is already in group' { + $testMembers = @( $script:testMemberName1 ) + + Set-TargetResourceOnNanoServer -GroupName $script:testGroupName -MembersToInclude $testMembers -Ensure 'Present' + + Assert-MockCalled -CommandName 'Add-LocalGroupMember' -Times 0 -Scope 'It' + Assert-MockCalled -CommandName 'Remove-LocalGroupMember' -Times 0 -Scope 'It' + } + + It 'Should not modify group if member specified by MembersToExclude is not in group' { + $testMembers = @( $script:testMemberName3 ) + + Set-TargetResourceOnNanoServer -GroupName $script:testGroupName -MembersToExclude $testMembers -Ensure 'Present' + + Assert-MockCalled -CommandName 'Add-LocalGroupMember' -Times 0 -Scope 'It' + Assert-MockCalled -CommandName 'Remove-LocalGroupMember' -Times 0 -Scope 'It' + } + + It 'Should not modify group if members specified by Members match group members' { + $testMembers = @( $script:testMemberName1, $script:testMemberName2 ) + + Set-TargetResourceOnNanoServer -GroupName $script:testGroupName -Members $testMembers -Ensure 'Present' + + Assert-MockCalled -CommandName 'Add-LocalGroupMember' -Times 0 -Scope 'It' + Assert-MockCalled -CommandName 'Remove-LocalGroupMember' -Times 0 -Scope 'It' + } + + It 'Should not modify group if MembersToInclude is empty' { + $testMembers = @( ) + + Set-TargetResourceOnNanoServer -GroupName $script:testGroupName -MembersToInclude $testMembers -Ensure 'Present' + + Assert-MockCalled -CommandName 'Add-LocalGroupMember' -Times 0 -Scope 'It' + Assert-MockCalled -CommandName 'Remove-LocalGroupMember' -Times 0 -Scope 'It' + } + + It 'Should not modify group if MembersToExclude is empty' { + $testMembers = @( ) + + Set-TargetResourceOnNanoServer -GroupName $script:testGroupName -MembersToExclude $testMembers -Ensure 'Present' + + Assert-MockCalled -CommandName 'Add-LocalGroupMember' -Times 0 -Scope 'It' + Assert-MockCalled -CommandName 'Remove-LocalGroupMember' -Times 0 -Scope 'It' + } + + It 'Should not modify group if both MembersToInclude and MembersToExclude are empty' { + $testMembers = @( ) + + Set-TargetResourceOnNanoServer -GroupName $script:testGroupName -MembersToInclude $testMembers -MembersToExclude $testMembers -Ensure 'Present' + + Assert-MockCalled -CommandName 'Add-LocalGroupMember' -Times 0 -Scope 'It' + Assert-MockCalled -CommandName 'Remove-LocalGroupMember' -Times 0 -Scope 'It' + } + + It 'Should remove an existing group when Ensure is Absent' { + Set-TargetResourceOnNanoServer -GroupName $script:testGroupName -Ensure 'Absent' + + Assert-MockCalled -CommandName 'Get-LocalGroup' -ParameterFilter { $Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Remove-LocalGroup' -ParameterFilter { $Name -eq $script:testGroupName } -Scope 'It' + } + + It 'Should not modify group if no changes were made' { + Set-TargetResourceOnNanoServer -GroupName $script:testGroupName -Ensure 'Present' + + Assert-MockCalled -CommandName 'Set-LocalGroup' -Times 0 -Scope 'It' + Assert-MockCalled -CommandName 'Add-LocalGroupMember' -Times 0 -Scope 'It' + Assert-MockCalled -CommandName 'Remove-LocalGroupMember' -Times 0 -Scope 'It' + } + } + + Context 'Test-TargetResourceOnNanoServer' { + Mock -CommandName 'Get-LocalGroup' -MockWith { Write-Error -Message 'Test error message' -CategoryReason 'GroupNotFoundException' } + Mock -CommandName 'Get-MembersOnNanoServer' -MockWith { } + + It 'Should return true for an absent group when Ensure is Absent' { + Test-TargetResourceOnNanoServer -GroupName $script:testGroupName -Ensure 'Absent' | Should Be $true + + Assert-MockCalled -CommandName 'Get-LocalGroup' -ParameterFilter { $Name -eq $script:testGroupName } + } + + It 'Should return false for an absent group when Ensure is Present' { + Test-TargetResourceOnNanoServer -GroupName $script:testGroupName -Ensure 'Present' | Should Be $false + + Assert-MockCalled -CommandName 'Get-LocalGroup' -ParameterFilter { $Name -eq $script:testGroupName } + } + + Mock -CommandName 'Get-LocalGroup' -MockWith { Write-Error -Message $script:testErrorMessage -CategoryReason 'OtherException' } + + It 'Should throw from group retrieval if exception is not a GroupNotFoundException' { + { Test-TargetResourceOnNanoServer -GroupName $script:testGroupName -Ensure 'Present' } | Should Throw $script:testErrorMessage + } + + Mock -CommandName 'Get-LocalGroup' -MockWith { return $script:testLocalGroup } + + It 'Should return true for an existing group when Ensure is Present' { + Test-TargetResourceOnNanoServer -GroupName $script:testGroupName -Ensure 'Present' | Should Be $true + + Assert-MockCalled -CommandName 'Get-LocalGroup' -ParameterFilter { $Name -eq $script:testGroupName } + } + + It 'Should return false for an existing group when Ensure is Absent' { + Test-TargetResourceOnNanoServer -GroupName $script:testGroupName -Ensure 'Absent' | Should Be $false + + Assert-MockCalled -CommandName 'Get-LocalGroup' -ParameterFilter { $Name -eq $script:testGroupName } + } + + It 'Should return true for an existing group with a matching description' { + $script:testLocalGroup.Description = $script:testGroupDescription + + Test-TargetResourceOnNanoServer -GroupName $script:testGroupName -Description $script:testGroupDescription -Ensure 'Present' | Should Be $true + + Assert-MockCalled -CommandName 'Get-LocalGroup' -ParameterFilter { $Name -eq $script:testGroupName } + } + + It 'Should return false for an existing group with a mismatching description' { + $script:testLocalGroup.Description = $script:testGroupDescription + + Test-TargetResourceOnNanoServer -GroupName $script:testGroupName -Description 'Wrong description' -Ensure 'Present' | Should Be $false + + Assert-MockCalled -CommandName 'Get-LocalGroup' -ParameterFilter { $Name -eq $script:testGroupName } + } + + It 'Should return true with matching empty members when using Members' { + $testMembers = @( ) + + Test-TargetResourceOnNanoServer -GroupName $script:testGroupName -Members $testMembers -Ensure 'Present' | Should Be $true + + Assert-MockCalled -CommandName 'Get-LocalGroup' -ParameterFilter { $Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Get-MembersOnNanoServer' -ParameterFilter { $Group.Name -eq $script:testGroupName } + } + + It 'Should return false with mismatching number of members when using Members' { + $testMembers = @( $script:testMemberName1 ) + + Test-TargetResourceOnNanoServer -GroupName $script:testGroupName -Members $testMembers -Ensure 'Present' | Should Be $false + + Assert-MockCalled -CommandName 'Get-LocalGroup' -ParameterFilter { $Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Get-MembersOnNanoServer' -ParameterFilter { $Group.Name -eq $script:testGroupName } + } + + It 'Should return false with missing member when using MembersToInclude' { + $testMembers = @( $script:testMemberName1 ) + + Test-TargetResourceOnNanoServer -GroupName $script:testGroupName -MembersToInclude $testMembers -Ensure 'Present' | Should Be $false + + Assert-MockCalled -CommandName 'Get-LocalGroup' -ParameterFilter { $Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Get-MembersOnNanoServer' -ParameterFilter { $Group.Name -eq $script:testGroupName } + } + + It 'Should return true with missing member when using MembersToExclude' { + $testMembers = @( $script:testMemberName1 ) + + Test-TargetResourceOnNanoServer -GroupName $script:testGroupName -MembersToExclude $testMembers -Ensure 'Present' | Should Be $true + + Assert-MockCalled -CommandName 'Get-LocalGroup' -ParameterFilter { $Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Get-MembersOnNanoServer' -ParameterFilter { $Group.Name -eq $script:testGroupName } + } + + Mock -CommandName 'Get-MembersOnNanoServer' -MockWith { @( $script:testMemberName1, $script:testMemberName2 ) } + + It 'Should return false when group contains member specified by MemberstoExclude' { + $testMembers = @( $script:testMemberName1 ) + + Test-TargetResourceOnNanoServer -GroupName $script:testGroupName -MembersToExclude $testMembers -Ensure 'Present' | Should Be $false + + Assert-MockCalled -CommandName 'Get-LocalGroup' -ParameterFilter { $Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Get-MembersOnNanoServer' -ParameterFilter { $Group.Name -eq $script:testGroupName } + } + + It 'Should return true when group contains member specified by MembersToInclude' { + $testMembers = @( $script:testMemberName1 ) + + Test-TargetResourceOnNanoServer -GroupName $script:testGroupName -MembersToInclude $testMembers -Ensure 'Present' | Should Be $true + + Assert-MockCalled -CommandName 'Get-LocalGroup' -ParameterFilter { $Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Get-MembersOnNanoServer' -ParameterFilter { $Group.Name -eq $script:testGroupName } + } + + It 'Should return true when group members match Members' { + $testMembers = @( $script:testMemberName1, $script:testMemberName2 ) + + Test-TargetResourceOnNanoServer -GroupName $script:testGroupName -Members $testMembers -Ensure 'Present' | Should Be $true + + Assert-MockCalled -CommandName 'Get-LocalGroup' -ParameterFilter { $Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Get-MembersOnNanoServer' -ParameterFilter { $Group.Name -eq $script:testGroupName } + } + + It 'Should return false when group members do not match Members' { + $testMembers = @( $script:testMemberName1, $script:testMemberName3 ) + + Test-TargetResourceOnNanoServer -GroupName $script:testGroupName -Members $testMembers -Ensure 'Present' | Should Be $false + + Assert-MockCalled -CommandName 'Get-LocalGroup' -ParameterFilter { $Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Get-MembersOnNanoServer' -ParameterFilter { $Group.Name -eq $script:testGroupName } + } + + It 'Should throw if Members and MembersToInclude are both specified' { + $testMembers = @( $script:testMemberName1, $script:testMemberName2 ) + $testMembersToInclude = @( $script:testMemberName3 ) + + $errorMessage = $script:localizedData.MembersAndIncludeExcludeConflict -f 'Members', 'MembersToInclude' + + { Test-TargetResourceOnNanoServer -GroupName $script:testGroupName -Members $testMembers -MembersToInclude $testMembersToInclude -Ensure 'Present' } | Should Throw $errorMessage + } + + It 'Should throw if Members and MembersToExclude are both specified' { + $testMembers = @( $script:testMemberName1, $script:testMemberName2 ) + $testMembersToExclude = @( $script:testMemberName3 ) + + $errorMessage = $script:localizedData.MembersAndIncludeExcludeConflict -f 'Members', 'MembersToExclude' + + { Test-TargetResourceOnNanoServer -GroupName $script:testGroupName -Members $testMembers -MembersToExclude $testMembersToExclude -Ensure 'Present' } | Should Throw $errorMessage + } + + It 'Should throw if MembersToInclude and MembersToExclude contain the same member' { + $testMembersToInclude = @( $script:testMemberName1 ) + $testMembersToExclude = @( $script:testMemberName1 ) + + $errorMessage = $script:localizedData.IncludeAndExcludeConflict -f $script:testMemberName1, 'MembersToInclude', 'MembersToExclude' + + { Test-TargetResourceOnNanoServer -GroupName $script:testGroupName -MembersToInclude $testMembersToInclude -MembersToExclude $testMembersToExclude -Ensure 'Present' } | Should Throw $errorMessage + } + } + + Context 'Get-MembersOnNanoServer' { + Mock -CommandName 'Get-LocalGroupMember' -MockWith { } + + It 'Should return nothing if group does not have members' { + Get-MembersOnNanoServer -Group $script:testLocalGroup | Should Be $null + + Assert-MockCalled -CommandName 'Get-LocalGroupMember' -ParameterFilter { $Group.Name -eq $script:testGroupName } + } + + $testDomainUser1 = @{ + Name = 'TestDomainUser1' + PrincipalSource = 'NotLocal' + } + + $testDomainUser2 = @{ + Name = 'TestDomainUser2' + PrincipalSource = 'Local' + } + + Mock -CommandName 'Get-LocalGroupMember' -MockWith { return @( $testDomainUser1, $testDomainUser2 ) } + + It 'Should return all local members and ignore non-local members' { + Get-MembersOnNanoServer -Group $script:testLocalGroup | Should Be @( $testDomainUser2.Name ) + + Assert-MockCalled -CommandName 'Get-LocalGroupMember' -ParameterFilter { $Group.Name -eq $script:testGroupName } + } + } + } + else + { + Context 'Get-TargetResourceOnFullSKU' { + $testMembers = @($script:testuserPrincipal1.Name, $script:testuserPrincipal2.Name) + + Mock -CommandName 'Get-Group' -MockWith { } + Mock -CommandName 'Get-MembersOnFullSKU' -MockWith { return @() } + Mock -CommandName 'Remove-DisposableObject' -MockWith { } + + It 'Should return Ensure as Absent when Get-Group returns null' { + $getTargetResourceResult = Get-TargetResourceOnFullSKU -GroupName $script:testGroupName + + Assert-MockCalled -CommandName 'Get-Group' -ParameterFilter { $GroupName -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Remove-DisposableObject' + + $getTargetResourceResult.GetType() | Should Be 'Hashtable' + $getTargetResourceResult.Keys.Count | Should Be 2 + $getTargetResourceResult.GroupName | Should Be $script:testGroupName + $getTargetResourceResult.Ensure | Should Be 'Absent' + } + + It 'Should return correct hashtable values when Get-Group returns a valid, existing group without members' { + $script:testGroup.Description = $script:testGroupDescription + + Mock -CommandName 'Get-Group' -MockWith { return $script:testGroup } + + $getTargetResourceResult = Get-TargetResourceOnFullSKU -GroupName $script:testGroupName + + Assert-MockCalled -CommandName 'Get-Group' -ParameterFilter { $GroupName -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Get-MembersOnFullSKU' -ParameterFilter { $Group -eq $script:testGroup } + Assert-MockCalled -CommandName 'Remove-DisposableObject' + + $getTargetResourceResult.GetType() | Should Be 'Hashtable' + $getTargetResourceResult.Keys.Count | Should Be 4 + $getTargetResourceResult.GroupName | Should Be $script:testGroupName + $getTargetResourceResult.Ensure | Should Be 'Present' + $getTargetResourceResult.Description | Should Be $script:testGroupDescription + $getTargetResourceResult.Members | Should Be $null + } + + It 'Should return correct hashtable values when Get-Group returns a valid, existing group with members' { + $testGroup.Description = $script:testGroupDescription + + Mock -CommandName 'Get-Group' -MockWith { return $script:testGroup } + Mock -CommandName 'Get-MembersOnFullSKU' -MockWith { return $testMembers } + + $getTargetResourceResult = Get-TargetResourceOnFullSKU -GroupName $script:testGroupName + + Assert-MockCalled -CommandName 'Get-Group' -ParameterFilter { $GroupName -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Get-MembersOnFullSKU' -ParameterFilter { $Group -eq $script:testGroup } + Assert-MockCalled -CommandName 'Remove-DisposableObject' + + $getTargetResourceResult.GetType() | Should Be 'Hashtable' + $getTargetResourceResult.Keys.Count | Should Be 4 + $getTargetResourceResult.GroupName | Should Be $script:testGroupName + $getTargetResourceResult.Ensure | Should Be 'Present' + $getTargetResourceResult.Description | Should Be $script:testGroupDescription + $getTargetResourceResult.Members | Should Be $testMembers + } + } + + Context 'Set-TargetResourceOnFullSKU' { + Mock -CommandName 'Get-Group' -MockWith { } + Mock -CommandName 'Get-MembersAsPrincipalsList' -MockWith { } + Mock -CommandName 'ConvertTo-UniquePrincipalsList' -MockWith { + $memberPrincipals = @() + + if ($MemberNames -contains $script:testUserPrincipal1.Name) + { + $memberPrincipals += @( $script:testUserPrincipal1` ) + } + + if ($MemberNames -contains $script:testUserPrincipal2.Name) + { + $memberPrincipals += @( $script:testUserPrincipal2 ) + } + + if ($MemberNames -contains $script:testUserPrincipal3.Name) + { + $memberPrincipals += @( $script:testUserPrincipal3 ) + } + + return $memberPrincipals + } + + Mock -CommandName 'Clear-GroupMembers' -MockWith { } + Mock -CommandName 'Add-GroupMember' -MockWith { } + Mock -CommandName 'Remove-GroupMember' -MockWith { } + Mock -CommandName 'Remove-Group' -MockWith { } + Mock -CommandName 'Save-Group' -MockWith { } + + Mock -CommandName 'Remove-DisposableObject' -MockWith { } + Mock -CommandName 'Get-PrincipalContext' -MockWith { return $script:testPrincipalContext } + + It 'Should not attempt to remove an absent group when Ensure is Absent' { + Set-TargetResourceOnFullSKU -GroupName $script:testGroupName -Ensure 'Absent' + + Assert-MockCalled -CommandName 'Get-PrincipalContext' -Scope 'It' + Assert-MockCalled -CommandName 'Get-Group' -ParameterFilter { $GroupName -eq $script:testGroupName } -Scope 'It' + Assert-MockCalled -CommandName 'Remove-Group' -ParameterFilter { $Group.Name -eq $script:testGroupName } -Times 0 -Scope 'It' + Assert-MockCalled -CommandName 'Remove-DisposableObject' -Scope 'It' + } + + It 'Should create an empty group' { + Set-TargetResourceOnFullSKU -GroupName $script:testGroupName -Ensure 'Present' + + Assert-MockCalled -CommandName 'Get-PrincipalContext' + Assert-MockCalled -CommandName 'Get-Group' -ParameterFilter { $GroupName -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Save-Group' -ParameterFilter { $Group.Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Remove-DisposableObject' + } + + It 'Should create an empty group with a description' { + Set-TargetResourceOnFullSKU -GroupName $script:testGroupName -Description $script:testGroupDescription -Ensure 'Present' + + Assert-MockCalled -CommandName 'Get-PrincipalContext' + Assert-MockCalled -CommandName 'Get-Group' -ParameterFilter { $GroupName -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Save-Group' -ParameterFilter { $Group.Name -eq $script:testGroupName -and $Group.Description -eq $script:testGroupDescription } + Assert-MockCalled -CommandName 'Remove-DisposableObject' + } + + It 'Should create a group with one local member using Members' { + $testMembers = @( $script:testUserPrincipal1.Name ) + + Mock -CommandName 'New-Object' -ParameterFilter { $TypeName -eq 'System.DirectoryServices.AccountManagement.GroupPrincipal' } -MockWith { return $testGroup } + + Set-TargetResourceOnFullSKU -GroupName $script:testGroupName -Members $testMembers -Ensure 'Present' + + Assert-MockCalled -CommandName 'Get-PrincipalContext' + Assert-MockCalled -CommandName 'Get-Group' -ParameterFilter { $GroupName -eq $script:testGroupName } + Assert-MockCalled -CommandName 'New-Object' -ParameterFilter { $TypeName -eq 'System.DirectoryServices.AccountManagement.GroupPrincipal' } + Assert-MockCalled -CommandName 'ConvertTo-UniquePrincipalsList' -ParameterFilter { $MemberNames -eq $testMembers } + Assert-MockCalled -CommandName 'Add-GroupMember' -ParameterFilter { $Group.Name -eq $script:testGroupName -and $MemberAsPrincipal -eq $script:testUserPrincipal1 } + Assert-MockCalled -CommandName 'Save-Group' -ParameterFilter { $Group.Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Remove-DisposableObject' + } + + It 'Should create a group with two local members using Members' { + $testMembers = @( $script:testUserPrincipal1.Name, $script:testUserPrincipal2.Name ) + + Mock -CommandName 'New-Object' -ParameterFilter { $TypeName -eq 'System.DirectoryServices.AccountManagement.GroupPrincipal' } -MockWith { return $testGroup } + + Set-TargetResourceOnFullSKU -GroupName $script:testGroupName -Members $testMembers -Ensure 'Present' + + Assert-MockCalled -CommandName 'Get-PrincipalContext' + Assert-MockCalled -CommandName 'Get-Group' -ParameterFilter { $GroupName -eq $script:testGroupName } + Assert-MockCalled -CommandName 'New-Object' -ParameterFilter { $TypeName -eq 'System.DirectoryServices.AccountManagement.GroupPrincipal' } + Assert-MockCalled -CommandName 'ConvertTo-UniquePrincipalsList' -ParameterFilter { (Compare-Object -ReferenceObject $testMembers -DifferenceObject $MemberNames) -eq $null } + Assert-MockCalled -CommandName 'Add-GroupMember' -ParameterFilter { $Group.Name -eq $script:testGroupName -and $MemberAsPrincipal -eq $script:testUserPrincipal1 } + Assert-MockCalled -CommandName 'Add-GroupMember' -ParameterFilter { $Group.Name -eq $script:testGroupName -and $MemberAsPrincipal -eq $script:testUserPrincipal2 } + Assert-MockCalled -CommandName 'Save-Group' -ParameterFilter { $Group.Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Remove-DisposableObject' + } + + It 'Should create a group with one local member using MembersToInclude' { + $testMembers = @( $script:testUserPrincipal1.Name ) + + Mock -CommandName 'New-Object' -ParameterFilter { $TypeName -eq 'System.DirectoryServices.AccountManagement.GroupPrincipal' } -MockWith { return $testGroup } + + Set-TargetResourceOnFullSKU -GroupName $script:testGroupName -MembersToInclude $testMembers -Ensure 'Present' + + Assert-MockCalled -CommandName 'Get-PrincipalContext' + Assert-MockCalled -CommandName 'Get-Group' -ParameterFilter { $GroupName -eq $script:testGroupName } + Assert-MockCalled -CommandName 'New-Object' -ParameterFilter { $TypeName -eq 'System.DirectoryServices.AccountManagement.GroupPrincipal' } + Assert-MockCalled -CommandName 'ConvertTo-UniquePrincipalsList' -ParameterFilter { $MemberNames -eq $testMembers } + Assert-MockCalled -CommandName 'Add-GroupMember' -ParameterFilter { $Group.Name -eq $script:testGroupName -and $MemberAsPrincipal -eq $script:testUserPrincipal1 } + Assert-MockCalled -CommandName 'Save-Group' -ParameterFilter { $Group.Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Remove-DisposableObject' + } + + It 'Should create a group with two local members using MembersToInclude' { + $testMembers = @( $script:testUserPrincipal1.Name, $script:testUserPrincipal2.Name ) + + Mock -CommandName 'New-Object' -ParameterFilter { $TypeName -eq 'System.DirectoryServices.AccountManagement.GroupPrincipal' } -MockWith { return $testGroup } + + Set-TargetResourceOnFullSKU -GroupName $script:testGroupName -MembersToInclude $testMembers -Ensure 'Present' + + Assert-MockCalled -CommandName 'Get-PrincipalContext' + Assert-MockCalled -CommandName 'Get-Group' -ParameterFilter { $GroupName -eq $script:testGroupName } + Assert-MockCalled -CommandName 'New-Object' -ParameterFilter { $TypeName -eq 'System.DirectoryServices.AccountManagement.GroupPrincipal' } + Assert-MockCalled -CommandName 'ConvertTo-UniquePrincipalsList' -ParameterFilter { (Compare-Object -ReferenceObject $testMembers -DifferenceObject $MemberNames) -eq $null } + Assert-MockCalled -CommandName 'Add-GroupMember' -ParameterFilter { $Group.Name -eq $script:testGroupName -and $MemberAsPrincipal -eq $script:testUserPrincipal1 } + Assert-MockCalled -CommandName 'Add-GroupMember' -ParameterFilter { $Group.Name -eq $script:testGroupName -and $MemberAsPrincipal -eq $script:testUserPrincipal2 } + Assert-MockCalled -CommandName 'Save-Group' -ParameterFilter { $Group.Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Remove-DisposableObject' + } + + Mock -CommandName 'Get-Group' -MockWith { return $script:testGroup } + + It 'Should add a member to an existing group with no members using Members' { + $testMembers = @( $script:testUserPrincipal1.Name ) + + Mock -CommandName 'Get-MembersAsPrincipalsList' -MockWith { return @() } + + Set-TargetResourceOnFullSKU -GroupName $script:testGroupName -Members $testMembers -Ensure 'Present' + + Assert-MockCalled -CommandName 'Get-PrincipalContext' + Assert-MockCalled -CommandName 'Get-Group' -ParameterFilter { $GroupName -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Get-MembersAsPrincipalsList' -ParameterFilter { $Group.Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'ConvertTo-UniquePrincipalsList' -ParameterFilter { (Compare-Object -ReferenceObject $testMembers -DifferenceObject $MemberNames) -eq $null } + Assert-MockCalled -CommandName 'Add-GroupMember' -ParameterFilter { $Group.Name -eq $script:testGroupName -and $MemberAsPrincipal -eq $script:testUserPrincipal1 } + Assert-MockCalled -CommandName 'Save-Group' -ParameterFilter { $Group.Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Remove-DisposableObject' + } + + It 'Should add two members to an existing group with one of the members using Members' { + $testMembers = @( $script:testUserPrincipal1.Name, $script:testUserPrincipal2.Name ) + + Mock -CommandName 'Get-MembersAsPrincipalsList' -MockWith { return @( $script:testUserPrincipal1 ) } + + Set-TargetResourceOnFullSKU -GroupName $script:testGroupName -Members $testMembers -Ensure 'Present' + + Assert-MockCalled -CommandName 'Get-PrincipalContext' + Assert-MockCalled -CommandName 'Get-Group' -ParameterFilter { $GroupName -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Get-MembersAsPrincipalsList' -ParameterFilter { $Group.Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'ConvertTo-UniquePrincipalsList' -ParameterFilter { (Compare-Object -ReferenceObject $testMembers -DifferenceObject $MemberNames) -eq $null } + Assert-MockCalled -CommandName 'Add-GroupMember' -ParameterFilter { $Group.Name -eq $script:testGroupName -and $MemberAsPrincipal -eq $script:testUserPrincipal2 } + Assert-MockCalled -CommandName 'Remove-GroupMember' -Times 0 + Assert-MockCalled -CommandName 'Save-Group' -ParameterFilter { $Group.Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Remove-DisposableObject' + } + + It 'Should add a member to an existing group with no members using MembersToInclude' { + $testMembers = @( $script:testUserPrincipal1.Name ) + + Mock -CommandName 'Get-MembersAsPrincipalsList' -MockWith { return @() } + + Set-TargetResourceOnFullSKU -GroupName $script:testGroupName -MembersToInclude $testMembers -Ensure 'Present' + + Assert-MockCalled -CommandName 'Get-PrincipalContext' + Assert-MockCalled -CommandName 'Get-Group' -ParameterFilter { $GroupName -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Get-MembersAsPrincipalsList' -ParameterFilter { $Group.Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'ConvertTo-UniquePrincipalsList' -ParameterFilter { (Compare-Object -ReferenceObject $testMembers -DifferenceObject $MemberNames) -eq $null } + Assert-MockCalled -CommandName 'Add-GroupMember' -ParameterFilter { $Group.Name -eq $script:testGroupName -and $MemberAsPrincipal -eq $script:testUserPrincipal1 } + Assert-MockCalled -CommandName 'Save-Group' -ParameterFilter { $Group.Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Remove-DisposableObject' + } + + It 'Should add two members to an existing group with one of the members using MembersToInclude' { + $testMembers = @( $script:testUserPrincipal1.Name, $script:testUserPrincipal2.Name ) + + Mock -CommandName 'Get-MembersAsPrincipalsList' -MockWith { return @( $script:testUserPrincipal1 ) } + + Set-TargetResourceOnFullSKU -GroupName $script:testGroupName -MembersToInclude $testMembers -Ensure 'Present' + + Assert-MockCalled -CommandName 'Get-PrincipalContext' + Assert-MockCalled -CommandName 'Get-Group' -ParameterFilter { $GroupName -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Get-MembersAsPrincipalsList' -ParameterFilter { $Group.Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'ConvertTo-UniquePrincipalsList' -ParameterFilter { (Compare-Object -ReferenceObject $testMembers -DifferenceObject $MemberNames) -eq $null } + Assert-MockCalled -CommandName 'Add-GroupMember' -ParameterFilter { $Group.Name -eq $script:testGroupName -and $MemberAsPrincipal -eq $script:testUserPrincipal2 } + Assert-MockCalled -CommandName 'Remove-GroupMember' -Times 0 + Assert-MockCalled -CommandName 'Save-Group' -ParameterFilter { $Group.Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Remove-DisposableObject' + } + + It 'Should remove a member from an existing group using Members' { + $testMembers = @( $script:testUserPrincipal1.Name ) + + Mock -CommandName 'Get-MembersAsPrincipalsList' -MockWith { return @( $script:testUserPrincipal1, $script:testUserPrincipal2 ) } + + Set-TargetResourceOnFullSKU -GroupName $script:testGroupName -Members $testMembers -Ensure 'Present' + + Assert-MockCalled -CommandName 'Get-PrincipalContext' + Assert-MockCalled -CommandName 'Get-Group' -ParameterFilter { $GroupName -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Get-MembersAsPrincipalsList' -ParameterFilter { $Group.Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'ConvertTo-UniquePrincipalsList' -ParameterFilter { (Compare-Object -ReferenceObject $testMembers -DifferenceObject $MemberNames) -eq $null } + Assert-MockCalled -CommandName 'Remove-GroupMember' -ParameterFilter { $Group.Name -eq $script:testGroupName -and $MemberAsPrincipal -eq $script:testUserPrincipal2 } + Assert-MockCalled -CommandName 'Save-Group' -ParameterFilter { $Group.Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Remove-DisposableObject' + } + + It 'Should clear group members from an existing group using Members' { + $testMembers = @( ) + + Mock -CommandName 'Get-MembersAsPrincipalsList' -MockWith { return @( $script:testUserPrincipal1, $script:testUserPrincipal2 ) } + + Set-TargetResourceOnFullSKU -GroupName $script:testGroupName -Members $testMembers -Ensure 'Present' + + Assert-MockCalled -CommandName 'Get-PrincipalContext' + Assert-MockCalled -CommandName 'Get-Group' -ParameterFilter { $GroupName -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Get-MembersAsPrincipalsList' -ParameterFilter { $Group.Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Clear-GroupMembers' -ParameterFilter { $Group.Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Save-Group' -ParameterFilter { $Group.Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Remove-DisposableObject' + } + + It 'Should remove a member from an existing group using MembersToExclude' { + $testMembers = @( $script:testUserPrincipal2.Name ) + + Mock -CommandName 'Get-MembersAsPrincipalsList' -MockWith { return @( $script:testUserPrincipal1, $script:testUserPrincipal2 ) } + + Set-TargetResourceOnFullSKU -GroupName $script:testGroupName -MembersToExclude $testMembers -Ensure 'Present' + + Assert-MockCalled -CommandName 'Get-PrincipalContext' + Assert-MockCalled -CommandName 'Get-Group' -ParameterFilter { $GroupName -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Get-MembersAsPrincipalsList' -ParameterFilter { $Group.Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'ConvertTo-UniquePrincipalsList' -ParameterFilter { (Compare-Object -ReferenceObject $testMembers -DifferenceObject $MemberNames) -eq $null } + Assert-MockCalled -CommandName 'Remove-GroupMember' -ParameterFilter { $Group.Name -eq $script:testGroupName -and $MemberAsPrincipal -eq $script:testUserPrincipal2 } + Assert-MockCalled -CommandName 'Save-Group' -ParameterFilter { $Group.Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Remove-DisposableObject' + } + + It 'Should add a user and remove a user using Members' { + $testMembers = @( $script:testUserPrincipal1.Name, $script:testUserPrincipal3.Name ) + + Mock -CommandName 'Get-MembersAsPrincipalsList' -MockWith { return @( $script:testUserPrincipal1, $script:testUserPrincipal2 ) } + + Set-TargetResourceOnFullSKU -GroupName $script:testGroupName -Members $testMembers -Ensure 'Present' + + Assert-MockCalled -CommandName 'Get-PrincipalContext' + Assert-MockCalled -CommandName 'Get-Group' -ParameterFilter { $GroupName -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Get-MembersAsPrincipalsList' -ParameterFilter { $Group.Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'ConvertTo-UniquePrincipalsList' -ParameterFilter { (Compare-Object -ReferenceObject $testMembers -DifferenceObject $MemberNames) -eq $null } + Assert-MockCalled -CommandName 'Add-GroupMember' -ParameterFilter { $Group.Name -eq $script:testGroupName -and $MemberAsPrincipal -eq $script:testUserPrincipal3 } + Assert-MockCalled -CommandName 'Remove-GroupMember' -ParameterFilter { $Group.Name -eq $script:testGroupName -and $MemberAsPrincipal -eq $script:testUserPrincipal2 } + Assert-MockCalled -CommandName 'Save-Group' -ParameterFilter { $Group.Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Remove-DisposableObject' + } + + It 'Should add a user and remove a user using MembersToInclude and MembersToExclude at the same time' { + $testMembersToInclude = @( $script:testUserPrincipal3.Name ) + $testMembersToExclude = @( $script:testUserPrincipal2.Name ) + + Mock -CommandName 'Get-MembersAsPrincipalsList' -MockWith { return @( $script:testUserPrincipal1, $script:testUserPrincipal2 ) } + + Set-TargetResourceOnFullSKU -GroupName $script:testGroupName -MembersToInclude $testMembersToInclude -MembersToExclude $testMembersToExclude -Ensure 'Present' + + Assert-MockCalled -CommandName 'Get-PrincipalContext' + Assert-MockCalled -CommandName 'Get-Group' -ParameterFilter { $GroupName -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Get-MembersAsPrincipalsList' -ParameterFilter { $Group.Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'ConvertTo-UniquePrincipalsList' -ParameterFilter { (Compare-Object -ReferenceObject $testMembersToInclude -DifferenceObject $MemberNames) -eq $null } + Assert-MockCalled -CommandName 'ConvertTo-UniquePrincipalsList' -ParameterFilter { (Compare-Object -ReferenceObject $testMembersToExclude -DifferenceObject $MemberNames) -eq $null } + Assert-MockCalled -CommandName 'Add-GroupMember' -ParameterFilter { $Group.Name -eq $script:testGroupName -and $MemberAsPrincipal -eq $script:testUserPrincipal3 } + Assert-MockCalled -CommandName 'Remove-GroupMember' -ParameterFilter { $Group.Name -eq $script:testGroupName -and $MemberAsPrincipal -eq $script:testUserPrincipal2 } + Assert-MockCalled -CommandName 'Save-Group' -ParameterFilter { $Group.Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Remove-DisposableObject' + } + + It 'Should throw if Members and MembersToInclude are both specified' { + $testMembers = @( $script:testUserPrincipal1.Name, $script:testUserPrincipal2.Name ) + $testMembersToInclude = @( $script:testUserPrincipal3.Name ) + + $errorMessage = $script:localizedData.MembersAndIncludeExcludeConflict -f 'Members', 'MembersToInclude' + + { Set-TargetResourceOnFullSKU -GroupName $script:testGroupName -Members $testMembers -MembersToInclude $testMembersToInclude -Ensure 'Present' } | Should Throw $errorMessage + } + + It 'Should throw if Members and MembersToExclude are both specified' { + $testMembers = @( $script:testUserPrincipal1.Name, $script:testUserPrincipal2.Name ) + $testMembersToExclude = @( $script:testUserPrincipal3.Name ) + + $errorMessage = $script:localizedData.MembersAndIncludeExcludeConflict -f 'Members', 'MembersToExclude' + + { Set-TargetResourceOnFullSKU -GroupName $script:testGroupName -Members $testMembers -MembersToExclude $testMembersToExclude -Ensure 'Present' } | Should Throw $errorMessage + } + + It 'Should throw if MembersToInclude and MembersToExclude contain the same member' { + $testMembersToInclude = @( $script:testUserPrincipal1.Name ) + $testMembersToExclude = @( $script:testUserPrincipal1.Name ) + + $errorMessage = $script:localizedData.IncludeAndExcludeConflict -f $script:testUserPrincipal1.Name, 'MembersToInclude', 'MembersToExclude' + + { Set-TargetResourceOnFullSKU -GroupName $script:testGroupName -MembersToInclude $testMembersToInclude -MembersToExclude $testMembersToExclude -Ensure 'Present' } | Should Throw $errorMessage + } + + It 'Should not modify group if member specified by MembersToInclude is already in group' { + $testMembers = @( $script:testUserPrincipal1.Name, $script:testUserPrincipal2.Name ) + + Mock -CommandName 'Get-MembersAsPrincipalsList' -MockWith { return @( $script:testUserPrincipal1, $script:testUserPrincipal2 ) } + + Set-TargetResourceOnFullSKU -GroupName $script:testGroupName -MembersToInclude $testMembers -Ensure 'Present' + + Assert-MockCalled -CommandName 'Clear-GroupMembers' -Times 0 -Scope 'It' + Assert-MockCalled -CommandName 'Add-GroupMember' -Times 0 -Scope 'It' + Assert-MockCalled -CommandName 'Remove-GroupMember' -Times 0 -Scope 'It' + Assert-MockCalled -CommandName 'Save-Group' -Times 0 -Scope 'It' + Assert-MockCalled -CommandName 'Remove-Group' -Times 0 -Scope 'It' + } + + It 'Should not modify group if member specified by MembersToExclude is not in group' { + $testMembers = @( $script:testUserPrincipal3.Name ) + + Mock -CommandName 'Get-MembersAsPrincipalsList' -MockWith { return @( $script:testUserPrincipal1, $script:testUserPrincipal2 ) } + + Set-TargetResourceOnFullSKU -GroupName $script:testGroupName -MembersToExclude $testMembers -Ensure 'Present' + + Assert-MockCalled -CommandName 'Clear-GroupMembers' -Times 0 -Scope 'It' + Assert-MockCalled -CommandName 'Add-GroupMember' -Times 0 -Scope 'It' + Assert-MockCalled -CommandName 'Remove-GroupMember' -Times 0 -Scope 'It' + Assert-MockCalled -CommandName 'Save-Group' -Times 0 -Scope 'It' + Assert-MockCalled -CommandName 'Remove-Group' -Times 0 -Scope 'It' + } + + It 'Should not modify group if members specified by Members match group members' { + $testMembers = @( $script:testUserPrincipal1.Name, $script:testUserPrincipal2.Name ) + + Mock -CommandName 'Get-MembersAsPrincipalsList' -MockWith { return @( $script:testUserPrincipal1, $script:testUserPrincipal2 ) } + + Set-TargetResourceOnFullSKU -GroupName $script:testGroupName -Members $testMembers -Ensure 'Present' + + Assert-MockCalled -CommandName 'Clear-GroupMembers' -Times 0 -Scope 'It' + Assert-MockCalled -CommandName 'Add-GroupMember' -Times 0 -Scope 'It' + Assert-MockCalled -CommandName 'Remove-GroupMember' -Times 0 -Scope 'It' + Assert-MockCalled -CommandName 'Save-Group' -Times 0 -Scope 'It' + Assert-MockCalled -CommandName 'Remove-Group' -Times 0 -Scope 'It' + } + + It 'Should not modify group if MembersToInclude is empty' { + $testMembers = @( ) + + Mock -CommandName 'Get-MembersAsPrincipalsList' -MockWith { return @( ) } + + Set-TargetResourceOnFullSKU -GroupName $script:testGroupName -MembersToInclude $testMembers -Ensure 'Present' + + Assert-MockCalled -CommandName 'Clear-GroupMembers' -Times 0 -Scope 'It' + Assert-MockCalled -CommandName 'Add-GroupMember' -Times 0 -Scope 'It' + Assert-MockCalled -CommandName 'Remove-GroupMember' -Times 0 -Scope 'It' + Assert-MockCalled -CommandName 'Save-Group' -Times 0 -Scope 'It' + Assert-MockCalled -CommandName 'Remove-Group' -Times 0 -Scope 'It' + } + + It 'Should not modify group if MembersToExclude is empty' { + $testMembers = @( ) + + Mock -CommandName 'Get-MembersAsPrincipalsList' -MockWith { return @( ) } + + Set-TargetResourceOnFullSKU -GroupName $script:testGroupName -MembersToExclude $testMembers -Ensure 'Present' + + Assert-MockCalled -CommandName 'Clear-GroupMembers' -Times 0 -Scope 'It' + Assert-MockCalled -CommandName 'Add-GroupMember' -Times 0 -Scope 'It' + Assert-MockCalled -CommandName 'Remove-GroupMember' -Times 0 -Scope 'It' + Assert-MockCalled -CommandName 'Save-Group' -Times 0 -Scope 'It' + Assert-MockCalled -CommandName 'Remove-Group' -Times 0 -Scope 'It' + } + + It 'Should not modify group if both MembersToInclude and MembersToExclude are empty' { + $testMembers = @( ) + + Mock -CommandName 'Get-MembersAsPrincipalsList' -MockWith { return @( ) } + + Set-TargetResourceOnFullSKU -GroupName $script:testGroupName -MembersToInclude $testMembers -MembersToExclude $testMembers -Ensure 'Present' + + Assert-MockCalled -CommandName 'Clear-GroupMembers' -Times 0 -Scope 'It' + Assert-MockCalled -CommandName 'Add-GroupMember' -Times 0 -Scope 'It' + Assert-MockCalled -CommandName 'Remove-GroupMember' -Times 0 -Scope 'It' + Assert-MockCalled -CommandName 'Save-Group' -Times 0 -Scope 'It' + Assert-MockCalled -CommandName 'Remove-Group' -Times 0 -Scope 'It' + } + + It 'Should not modify group with no members if Members is empty' { + $testMembers = @( ) + + Mock -CommandName 'Get-MembersAsPrincipalsList' -MockWith { } + + Set-TargetResourceOnFullSKU -GroupName $script:testGroupName -Members $testMembers -Ensure 'Present' + + Assert-MockCalled -CommandName 'Clear-GroupMembers' -Times 0 -Scope 'It' + Assert-MockCalled -CommandName 'Add-GroupMember' -Times 0 -Scope 'It' + Assert-MockCalled -CommandName 'Remove-GroupMember' -Times 0 -Scope 'It' + Assert-MockCalled -CommandName 'Save-Group' -Times 0 -Scope 'It' + Assert-MockCalled -CommandName 'Remove-Group' -Times 0 -Scope 'It' + } + + It 'Should remove an existing group when Ensure is Absent' { + Set-TargetResourceOnFullSKU -GroupName $script:testGroupName -Ensure 'Absent' + + Assert-MockCalled -CommandName 'Get-PrincipalContext' -Scope 'It' + Assert-MockCalled -CommandName 'Get-Group' -ParameterFilter { $GroupName -eq $script:testGroupName } -Scope 'It' + Assert-MockCalled -CommandName 'Remove-Group' -ParameterFilter { $Group.Name -eq $script:testGroupName } -Scope 'It' + Assert-MockCalled -CommandName 'Remove-DisposableObject' -Scope 'It' + } + + It 'Should not save group if no changes were made' { + Set-TargetResourceOnFullSKU -GroupName $script:testGroupName -Ensure 'Present' + + Assert-MockCalled -CommandName 'Get-PrincipalContext' -Scope 'It' + Assert-MockCalled -CommandName 'Get-Group' -ParameterFilter { $GroupName -eq $script:testGroupName } -Scope 'It' + Assert-MockCalled -CommandName 'Save-Group' -Times 0 -Scope 'It' + Assert-MockCalled -CommandName 'Remove-DisposableObject' -Scope 'It' + } + + It 'Should pass Credential to all appropriate functions when using Members' { + $testMembers = @( $script:testUserPrincipal1.Name ) + + Mock -CommandName 'Get-MembersAsPrincipalsList' -MockWith { return @() } + + Set-TargetResourceOnFullSKU -GroupName $script:testGroupName -Members $testMembers -Credential $script:testCredential -Ensure 'Present' + + Assert-MockCalled -CommandName 'Get-MembersAsPrincipalsList' -ParameterFilter { $Credential -eq $script:testCredential } + Assert-MockCalled -CommandName 'ConvertTo-UniquePrincipalsList' -ParameterFilter { $Credential -eq $script:testCredential } + } + + It 'Should pass Credential to all appropriate functions when using MembersToInclude and MembersToExclude' { + $testMembers = @( $script:testUserPrincipal1.Name ) + + Mock -CommandName 'Get-MembersAsPrincipalsList' -MockWith { return @() } + + Set-TargetResourceOnFullSKU -GroupName $script:testGroupName -Members $testMembers -Credential $script:testCredential -Ensure 'Present' + + Assert-MockCalled -CommandName 'Get-MembersAsPrincipalsList' -ParameterFilter { $Credential -eq $script:testCredential } + Assert-MockCalled -CommandName 'ConvertTo-UniquePrincipalsList' -ParameterFilter { $Credential -eq $script:testCredential } + } + + + } + + Context 'Test-TargetResourceOnFullSKU' { + Mock -CommandName 'Get-Group' -MockWith { } + Mock -CommandName 'Get-MembersAsPrincipalsList' -MockWith { } + Mock -CommandName 'ConvertTo-UniquePrincipalsList' -MockWith { + $memberPrincipals = @() + + if ($MemberNames -contains $script:testUserPrincipal1.Name) + { + $memberPrincipals += @( $script:testUserPrincipal1` ) + } + + if ($MemberNames -contains $script:testUserPrincipal2.Name) + { + $memberPrincipals += @( $script:testUserPrincipal2 ) + } + + if ($MemberNames -contains $script:testUserPrincipal3.Name) + { + $memberPrincipals += @( $script:testUserPrincipal3 ) + } + + return $memberPrincipals + } + + Mock -CommandName 'Remove-DisposableObject' -MockWith { } + Mock -CommandName 'Get-PrincipalContext' -MockWith { return $script:testPrincipalContext } + + It 'Should return true for an absent group when Ensure is Absent' { + Test-TargetResourceOnFullSKU -GroupName $script:testGroupName -Ensure 'Absent' | Should Be $true + + Assert-MockCalled -CommandName 'Get-PrincipalContext' -Scope 'It' + Assert-MockCalled -CommandName 'Get-Group' -ParameterFilter { $GroupName -eq $script:testGroupName } -Scope 'It' + Assert-MockCalled -CommandName 'Remove-DisposableObject' + } + + It 'Should return false for an absent group when Ensure is Present' { + Test-TargetResourceOnFullSKU -GroupName $script:testGroupName -Ensure 'Present' | Should Be $false + + Assert-MockCalled -CommandName 'Get-PrincipalContext' + Assert-MockCalled -CommandName 'Get-Group' -ParameterFilter { $GroupName -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Remove-DisposableObject' + } + + Mock -CommandName 'Get-Group' -MockWith { return $script:testGroup } + + It 'Should return true for an existing group when Ensure is Present' { + Test-TargetResourceOnFullSKU -GroupName $script:testGroupName -Ensure 'Present' | Should Be $true + + Assert-MockCalled -CommandName 'Get-PrincipalContext' + Assert-MockCalled -CommandName 'Get-Group' -ParameterFilter { $GroupName -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Remove-DisposableObject' + } + + It 'Should return false for an existing group when Ensure is Absent' { + Test-TargetResourceOnFullSKU -GroupName $script:testGroupName -Ensure 'Absent' | Should Be $false + + Assert-MockCalled -CommandName 'Get-PrincipalContext' -Scope 'It' + Assert-MockCalled -CommandName 'Get-Group' -ParameterFilter { $GroupName -eq $script:testGroupName } -Scope 'It' + Assert-MockCalled -CommandName 'Remove-DisposableObject' + } + + It 'Should return true for an existing group with a matching description' { + $script:testGroup.Description = $script:testGroupDescription + + Test-TargetResourceOnFullSKU -GroupName $script:testGroupName -Description $script:testGroupDescription -Ensure 'Present' | Should Be $true + + Assert-MockCalled -CommandName 'Get-PrincipalContext' + Assert-MockCalled -CommandName 'Get-Group' -ParameterFilter { $GroupName -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Remove-DisposableObject' + } + + It 'Should return false for an existing group with a mismatching description' { + $script:testGroup.Description = $script:testGroupDescription + + Test-TargetResourceOnFullSKU -GroupName $script:testGroupName -Description 'Wrong description' -Ensure 'Present' | Should Be $false + + Assert-MockCalled -CommandName 'Get-PrincipalContext' + Assert-MockCalled -CommandName 'Get-Group' -ParameterFilter { $GroupName -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Remove-DisposableObject' + } + + It 'Should return true with matching empty members when using Members' { + $testMembers = @( ) + + Test-TargetResourceOnFullSKU -GroupName $script:testGroupName -Members $testMembers -Ensure 'Present' | Should Be $true + + Assert-MockCalled -CommandName 'Get-PrincipalContext' + Assert-MockCalled -CommandName 'Get-Group' -ParameterFilter { $GroupName -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Get-MembersAsPrincipalsList' -ParameterFilter { $Group.Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Remove-DisposableObject' + } + + It 'Should return false with mismatching number of members when using Members' { + $testMembers = @( $script:testUserPrincipal1.Name ) + + Test-TargetResourceOnFullSKU -GroupName $script:testGroupName -Members $testMembers -Ensure 'Present' | Should Be $false + + Assert-MockCalled -CommandName 'Get-PrincipalContext' + Assert-MockCalled -CommandName 'Get-Group' -ParameterFilter { $GroupName -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Get-MembersAsPrincipalsList' -ParameterFilter { $Group.Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Remove-DisposableObject' + } + + It 'Should return false with missing member when using MembersToInclude' { + $testMembers = @( $script:testUserPrincipal1.Name ) + + Test-TargetResourceOnFullSKU -GroupName $script:testGroupName -MembersToInclude $testMembers -Ensure 'Present' | Should Be $false + + Assert-MockCalled -CommandName 'Get-PrincipalContext' + Assert-MockCalled -CommandName 'Get-Group' -ParameterFilter { $GroupName -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Get-MembersAsPrincipalsList' -ParameterFilter { $Group.Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Remove-DisposableObject' + } + + It 'Should return true with missing member when using MembersToExclude' { + $testMembers = @( $script:testUserPrincipal1.Name ) + + Test-TargetResourceOnFullSKU -GroupName $script:testGroupName -MembersToExclude $testMembers -Ensure 'Present' | Should Be $true + + Assert-MockCalled -CommandName 'Get-PrincipalContext' + Assert-MockCalled -CommandName 'Get-Group' -ParameterFilter { $GroupName -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Get-MembersAsPrincipalsList' -ParameterFilter { $Group.Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Remove-DisposableObject' + } + + Mock -CommandName 'Get-MembersAsPrincipalsList' -MockWith { @( $script:testUserPrincipal1, $script:testUserPrincipal2 ) } + + It 'Should return false when group contains member specified by MemberstoExclude' { + $testMembers = @( $script:testUserPrincipal1.Name ) + + Test-TargetResourceOnFullSKU -GroupName $script:testGroupName -MembersToExclude $testMembers -Ensure 'Present' | Should Be $false + + Assert-MockCalled -CommandName 'Get-PrincipalContext' + Assert-MockCalled -CommandName 'Get-Group' -ParameterFilter { $GroupName -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Get-MembersAsPrincipalsList' -ParameterFilter { $Group.Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Remove-DisposableObject' + } + + It 'Should return true when group contains member specified by MembersToInclude' { + $testMembers = @( $script:testUserPrincipal1.Name ) + + Test-TargetResourceOnFullSKU -GroupName $script:testGroupName -MembersToInclude $testMembers -Ensure 'Present' | Should Be $true + + Assert-MockCalled -CommandName 'Get-PrincipalContext' + Assert-MockCalled -CommandName 'Get-Group' -ParameterFilter { $GroupName -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Get-MembersAsPrincipalsList' -ParameterFilter { $Group.Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Remove-DisposableObject' + } + + It 'Should return true when group members match Members' { + $testMembers = @( $script:testUserPrincipal1, $script:testUserPrincipal2 ) + + Test-TargetResourceOnFullSKU -GroupName $script:testGroupName -Members $testMembers -Ensure 'Present' | Should Be $true + + Assert-MockCalled -CommandName 'Get-PrincipalContext' + Assert-MockCalled -CommandName 'Get-Group' -ParameterFilter { $GroupName -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Get-MembersAsPrincipalsList' -ParameterFilter { $Group.Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Remove-DisposableObject' + } + + It 'Should return false when group members do not match Members' { + $testMembers = @( $script:testUserPrincipal1, $script:testUserPrincipal3 ) + + Test-TargetResourceOnFullSKU -GroupName $script:testGroupName -Members $testMembers -Ensure 'Present' | Should Be $false + + Assert-MockCalled -CommandName 'Get-PrincipalContext' + Assert-MockCalled -CommandName 'Get-Group' -ParameterFilter { $GroupName -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Get-MembersAsPrincipalsList' -ParameterFilter { $Group.Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'Remove-DisposableObject' + } + + It 'Should pass Credential to all appropriate functions when using Members' { + $testMembers = @( $script:testUserPrincipal1.Name ) + + $null = Test-TargetResourceOnFullSKU -GroupName $script:testGroupName -Members $testMembers -Credential $script:testCredential -Ensure 'Present' + + Assert-MockCalled -CommandName 'Get-MembersAsPrincipalsList' -ParameterFilter { $Credential -eq $script:testCredential } + Assert-MockCalled -CommandName 'ConvertTo-UniquePrincipalsList' -ParameterFilter { $Credential -eq $script:testCredential } + } + + It 'Should pass Credential to all appropriate functions when using MembersToInclude and MembersToExclude' { + $testMembers = @( $script:testUserPrincipal1.Name ) + + $null = Test-TargetResourceOnFullSKU -GroupName $script:testGroupName -Members $testMembers -Credential $script:testCredential -Ensure 'Present' + + Assert-MockCalled -CommandName 'Get-MembersAsPrincipalsList' -ParameterFilter { $Credential -eq $script:testCredential } + Assert-MockCalled -CommandName 'ConvertTo-UniquePrincipalsList' -ParameterFilter { $Credential -eq $script:testCredential } + } + + It 'Should throw if Members and MembersToInclude are both specified' { + $testMembers = @( $script:testUserPrincipal1.Name, $script:testUserPrincipal2.Name ) + $testMembersToInclude = @( $script:testUserPrincipal3.Name ) + + $errorMessage = $script:localizedData.MembersAndIncludeExcludeConflict -f 'Members', 'MembersToInclude' + + { Test-TargetResourceOnFullSKU -GroupName $script:testGroupName -Members $testMembers -MembersToInclude $testMembersToInclude -Ensure 'Present' } | Should Throw $errorMessage + } + + It 'Should throw if Members and MembersToExclude are both specified' { + $testMembers = @( $script:testUserPrincipal1.Name, $script:testUserPrincipal2.Name ) + $testMembersToExclude = @( $script:testUserPrincipal3.Name ) + + $errorMessage = $script:localizedData.MembersAndIncludeExcludeConflict -f 'Members', 'MembersToExclude' + + { Test-TargetResourceOnFullSKU -GroupName $script:testGroupName -Members $testMembers -MembersToExclude $testMembersToExclude -Ensure 'Present' } | Should Throw $errorMessage + } + + It 'Should throw if MembersToInclude and MembersToExclude contain the same member' { + $testMembersToInclude = @( $script:testUserPrincipal1.Name ) + $testMembersToExclude = @( $script:testUserPrincipal1.Name ) + + $errorMessage = $script:localizedData.IncludeAndExcludeConflict -f $script:testUserPrincipal1.Name, 'MembersToInclude', 'MembersToExclude' + + { Test-TargetResourceOnFullSKU -GroupName $script:testGroupName -MembersToInclude $testMembersToInclude -MembersToExclude $testMembersToExclude -Ensure 'Present' } | Should Throw $errorMessage + } + } + + Context 'Get-MembersOnFullSKU' { + $principalContextCache = @{} + $disposables = New-Object -TypeName 'System.Collections.ArrayList' + + Mock -CommandName 'Get-MembersAsPrincipalsList' -MockWith { } + + It 'Should return nothing if group does not have members' { + Get-MembersOnFullSKU -Group $script:testGroup -PrincipalContextCache $principalContextCache -Disposables $disposables | Should Be $null + + Assert-MockCalled -CommandName 'Get-MembersAsPrincipalsList' -ParameterFilter { $Group.Name -eq $script:testGroupName } + } + + Mock -CommandName 'Get-MembersAsPrincipalsList' -MockWith { return @( $script:testUserPrincipal1, $script:testUserPrincipal2 ) } + + It 'Should return principal names for members without domains' { + Get-MembersOnFullSKU -Group $script:testGroup -PrincipalContextCache $principalContextCache -Disposables $disposables | Should Be @( $script:testUserPrincipal1.Name, $script:testUserPrincipal2.Name ) + + Assert-MockCalled -CommandName 'Get-MembersAsPrincipalsList' -ParameterFilter { $Group.Name -eq $script:testGroupName } + } + + $testDomainUser1 = @{ + Name = 'TestDomainUser1' + SamAccountName = 'TestSamAccountName1' + ContextType = [System.DirectoryServices.AccountManagement.ContextType]::Domain + Context = @{ + Name = 'TestDomain1' + } + StructuralObjectClass = 'Domain' + } + + $domainName2 = 'TestDomain2' + + $testDomainUser2 = @{ + Name = 'TestDomainUser2' + SamAccountName = 'TestSamAccountName2' + ContextType = [System.DirectoryServices.AccountManagement.ContextType]::Domain + Context = @{ + Name = "$domainName2.WithDot" + } + StructuralObjectClass = 'Computer' + } + + Mock -CommandName 'Get-MembersAsPrincipalsList' -MockWith { return @( $testDomainUser1, $testDomainUser2 ) } + + It 'Should return principal names for members with domains' { + $expectedName1 = "$($testDomainUser1.Context.Name)\$($testDomainUser1.SamAccountName)" + $expectedName2 = "$($domainName2)\$($testDomainUser2.Name)" + + $expectedGetMembersResult = @( $expectedName1, $expectedName2 ) + + $getMembersResult = Get-MembersOnFullSKU -Group $script:testGroup -PrincipalContextCache $principalContextCache -Disposables $disposables + + (Compare-Object -ReferenceObject $expectedGetMembersResult -DifferenceObject $getMembersResult) | Should Be $null + + Assert-MockCalled -CommandName 'Get-MembersAsPrincipalsList' -ParameterFilter { $Group.Name -eq $script:testGroupName } + } + } + + Context 'Get-MembersAsPrincipalsList' { + $principalContextCache = @{} + $disposables = New-Object -TypeName 'System.Collections.ArrayList' + + Mock -CommandName 'Get-GroupMembersFromDirectoryEntry' -MockWith { } + + Mock -CommandName 'New-Object' -ParameterFilter { $TypeName -eq 'System.DirectoryServices.DirectoryEntry' } -MockWith { + return $ArgumentList[0] + } + + Mock -CommandName 'Get-PrincipalContext' -MockWith { return $script:testPrincipalContext } + Mock -CommandName 'Test-IsLocalMachine' -MockWith { return $true } + + Mock -CommandName 'New-Object' -ParameterFilter { $TypeName -eq 'System.Security.Principal.SecurityIdentifier' } -MockWith { + return 'S-1-0-0' + } + + Mock -CommandName 'Resolve-SidToPrincipal' -MockWith { return 'FakeSidValue' } + + It 'Should return empty list when there are no group members' { + Get-MembersAsPrincipalsList -Group $script:testGroup -PrincipalContextCache $principalContextCache -Disposables $disposables | Should Be $null + Assert-MockCalled -CommandName 'Get-GroupMembersFromDirectoryEntry' -ParameterFilter { $Group.Name -eq $script:testGroupName } + } + + $memberDirectoryEntry1 = @{ + Path = 'WinNT://domainname/accountname' + Properties = @{ + ObjectSid = @{ + Value = 'SidValue1' + } + } + } + + $memberDirectoryEntry2 = @{ + Path = 'WinNT://domainname/machinename/accountname' + Properties = @{ + ObjectSid = @{ + Value = 'SidValue2' + } + } + } + + $memberDirectoryEntry3 = @{ + Path ='accountname' + } + + Mock 'Get-GroupMembersFromDirectoryEntry' { return @( $memberDirectoryEntry3 ) } + + It 'Should ignore stale members - Expected to write a warning' { + $getMembersResult = Get-MembersAsPrincipalsList -Group $script:testGroup -PrincipalContextCache $principalContextCache -Disposables $disposables | Should Be $null + + Assert-MockCalled -CommandName 'Get-GroupMembersFromDirectoryEntry' -ParameterFilter { $Group.Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'New-Object' -ParameterFilter { $TypeName -eq 'System.DirectoryServices.DirectoryEntry' } + } + + Mock 'Get-GroupMembersFromDirectoryEntry' { return @( $memberDirectoryEntry1, $memberDirectoryEntry2 ) } + + It 'Should return current members' { + $getMembersResult = Get-MembersAsPrincipalsList -Group $script:testGroup -PrincipalContextCache $principalContextCache -Disposables $disposables + $getMembersResult.Count | Should Be 2 + + Assert-MockCalled -CommandName 'Get-GroupMembersFromDirectoryEntry' -ParameterFilter { $Group.Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'New-Object' -ParameterFilter { $TypeName -eq 'System.DirectoryServices.DirectoryEntry' } -Times 2 -Scope 'It' + Assert-MockCalled -CommandName 'Get-PrincipalContext' -ParameterFilter { $Scope -eq 'domainname' } + Assert-MockCalled -CommandName 'Get-PrincipalContext' -ParameterFilter { $Scope -eq 'machinename' } + Assert-MockCalled -CommandName 'Test-IsLocalMachine' -ParameterFilter { $Scope -eq 'domainname' } + Assert-MockCalled -CommandName 'Test-IsLocalMachine' -ParameterFilter { $Scope -eq 'machinename' } + Assert-MockCalled -CommandName 'New-Object' -ParameterFilter { $TypeName -eq 'System.Security.Principal.SecurityIdentifier' -and $ArgumentList[0] -eq 'SidValue1' } + Assert-MockCalled -CommandName 'New-Object' -ParameterFilter { $TypeName -eq 'System.Security.Principal.SecurityIdentifier' -and $ArgumentList[0] -eq 'SidValue2' } + Assert-MockCalled -CommandName 'Resolve-SidToPrincipal' -ParameterFilter { $Sid -eq 'S-1-0-0' -and $Scope -eq 'domainname' } + Assert-MockCalled -CommandName 'Resolve-SidToPrincipal' -ParameterFilter { $Sid -eq 'S-1-0-0' -and $Scope -eq 'machinename' } + } + + Mock -CommandName 'Test-IsLocalMachine' -MockWith { return $false } + + It 'Should return current members with custom domain when prinicpal can be found' { + $getMembersResult = Get-MembersAsPrincipalsList -Group $script:testGroup -PrincipalContextCache $principalContextCache -Disposables $disposables + $getMembersResult.Count | Should Be 2 + + Assert-MockCalled -CommandName 'Get-GroupMembersFromDirectoryEntry' -ParameterFilter { $Group.Name -eq $script:testGroupName } + Assert-MockCalled -CommandName 'New-Object' -ParameterFilter { $TypeName -eq 'System.DirectoryServices.DirectoryEntry' } -Times 2 -Scope 'It' + Assert-MockCalled -CommandName 'Get-PrincipalContext' -ParameterFilter { $Scope -eq 'domainname' } + Assert-MockCalled -CommandName 'Get-PrincipalContext' -ParameterFilter { $Scope -eq 'machinename' } + Assert-MockCalled -CommandName 'Test-IsLocalMachine' -ParameterFilter { $Scope -eq 'domainname' } + Assert-MockCalled -CommandName 'Test-IsLocalMachine' -ParameterFilter { $Scope -eq 'machinename' } + Assert-MockCalled -CommandName 'New-Object' -ParameterFilter { $TypeName -eq 'System.Security.Principal.SecurityIdentifier' -and $ArgumentList[0] -eq 'SidValue1' } + Assert-MockCalled -CommandName 'New-Object' -ParameterFilter { $TypeName -eq 'System.Security.Principal.SecurityIdentifier' -and $ArgumentList[0] -eq 'SidValue2' } + Assert-MockCalled -CommandName 'Resolve-SidToPrincipal' -ParameterFilter { $Sid -eq 'S-1-0-0' -and $Scope -eq 'domainname' } + Assert-MockCalled -CommandName 'Resolve-SidToPrincipal' -ParameterFilter { $Sid -eq 'S-1-0-0' -and $Scope -eq 'machinename' } + } + + It 'Should pass Credential to appropriate functions' { + $getMembersResult = Get-MembersAsPrincipalsList -Group $script:testGroup -Credential $script:testCredential -PrincipalContextCache $principalContextCache -Disposables $disposables + + Assert-MockCalled -CommandName 'Get-PrincipalContext' -ParameterFilter { $Credential -eq $script:testCredential } + Assert-MockCalled -CommandName 'Get-PrincipalContext' -ParameterFilter { $Credential -eq $script:testCredential} + } + + Mock -CommandName 'Get-PrincipalContext' -MockWith { } + + It 'Should throw when prinicpal context for custom domain cannot be found' { + $errorMessage = ($script:localizedData.DomainCredentialsRequired -f 'accountname') + + { Get-MembersAsPrincipalsList -Group $script:testGroup -PrincipalContextCache $principalContextCache -Disposables $disposables } | Should Throw $errorMessage + } + } + + Context 'ConvertTo-UniquePrincipalsList' { + $principalContextCache = @{} + $disposables = New-Object -TypeName 'System.Collections.ArrayList' + + $testDomainUser1 = @{ + Name = 'TestDomainUser1' + SamAccountName = 'TestSamAccountName1' + ContextType = [System.DirectoryServices.AccountManagement.ContextType]::Domain + Context = @{ + Name = 'TestDomain1' + } + StructuralObjectClass = 'Domain' + DistinguishedName = 'TestDomainUser1' + } + + Mock -CommandName 'ConvertTo-Principal' -MockWith { + switch ($MemberName) + { + $script:testUserPrincipal1.Name { return $script:testUserPrincipal1 } + $script:testUserPrincipal2.Name { return $script:testUserPrincipal2 } + $script:testUserPrincipal3.Name { return $script:testUserPrincipal3 } + $testDomainUser1.Name { return $testDomainUser1 } + } + } + + It 'Should not return duplicate local prinicpals' { + $memberNames = @( $script:testUserPrincipal1.Name, $script:testUserPrincipal1.Name, $script:testUserPrincipal2.Name ) + + $uniquePrincipalsList = ConvertTo-UniquePrincipalsList -MemberNames $memberNames -PrincipalContextCache $principalContextCache -Disposables $disposables + $uniquePrincipalsList | Should Be @( $script:testUserPrincipal1, $script:testUserPrincipal2 ) + + foreach ($passedInMemberName in $memberNames) + { + Assert-MockCalled -CommandName 'ConvertTo-Principal' -ParameterFilter { $MemberName -eq $passedInMemberName } + } + } + + It 'Should not return duplicate domain prinicpals' { + $memberNames = @( $testDomainUser1.Name, $testDomainUser1.Name ) + + $uniquePrincipalsList = ConvertTo-UniquePrincipalsList -MemberNames $memberNames -PrincipalContextCache $principalContextCache -Disposables $disposables + $uniquePrincipalsList | Should Be @( $testDomainUser1 ) + + foreach ($passedInMemberName in $memberNames) + { + Assert-MockCalled -CommandName 'ConvertTo-Principal' -ParameterFilter { $MemberName -eq $passedInMemberName } + } + } + + It 'Should pass Credential to appropriate functions' { + ConvertTo-UniquePrincipalsList -MemberNames @( $script:testUserPrincipal1 ) -Credential $script:testCredential -PrincipalContextCache $principalContextCache -Disposables $disposables + + Assert-MockCalled -CommandName 'ConvertTo-Principal' -ParameterFilter { $Credential -eq $script:testCredential } + } + } + + Context 'ConvertTo-Principal' { + $principalContextCache = @{} + $disposables = New-Object -TypeName 'System.Collections.ArrayList' + + Mock -CommandName 'Split-MemberName' -MockWith { return $script:localDomain, $MemberName } + Mock -CommandName 'Test-IsLocalMachine' -MockWith { return $true } + Mock -CommandName 'Get-PrincipalContext' -MockWith { return $script:testPrincipalContext } + Mock -CommandName 'Find-Principal' -MockWith { + switch ($IdentityValue) + { + $script:testUserPrincipal1.Name { return $script:testUserPrincipal1 } + $script:testUserPrincipal2.Name { return $script:testUserPrincipal2 } + $script:testUserPrincipal3.Name { return $script:testUserPrincipal3 } + } + } + + It 'Should return principal with local member name' { + $convertToPrincipalResult = ConvertTo-Principal ` + -MemberName $script:testUserPrincipal1.Name ` + -PrincipalContextCache $principalContextCache ` + -Disposables $disposables + + $convertToPrincipalResult | Should Be $script:testUserPrincipal1 + + Assert-MockCalled -CommandName 'Split-MemberName' -ParameterFilter { $MemberName -eq $script:testUserPrincipal1.Name } + Assert-MockCalled -CommandName 'Test-IsLocalMachine' -ParameterFilter { $Scope -eq $script:localDomain } + Assert-MockCalled -CommandName 'Get-PrincipalContext' -ParameterFilter { $Scope -eq $script:localDomain } + Assert-MockCalled -CommandName 'Find-Principal' -ParameterFilter { $IdentityValue -eq $script:testUserPrincipal1.Name } + } + + Mock -CommandName 'Test-IsLocalMachine' -MockWith { return $false } + + It 'Should attempt to resolve non-local member with domain trust' { + $convertToPrincipalResult = ConvertTo-Principal ` + -MemberName $script:testUserPrincipal1.Name ` + -PrincipalContextCache $principalContextCache ` + -Disposables $disposables + + $convertToPrincipalResult | Should Be $script:testUserPrincipal1 + + Assert-MockCalled -CommandName 'Split-MemberName' -ParameterFilter { $MemberName -eq $script:testUserPrincipal1.Name } + Assert-MockCalled -CommandName 'Test-IsLocalMachine' -ParameterFilter { $Scope -eq $script:localDomain } + Assert-MockCalled -CommandName 'Get-PrincipalContext' -ParameterFilter { $Scope -eq $script:localDomain } + Assert-MockCalled -CommandName 'Find-Principal' -ParameterFilter { $IdentityValue -eq $script:testUserPrincipal1.Name } + } + + It 'Should pass Credential to appropriate functions' { + $null = ConvertTo-Principal -MemberName $script:testUserPrincipal1.Name -Credential $script:testCredential -PrincipalContextCache $principalContextCache -Disposables $disposables + + Assert-MockCalled -CommandName 'Get-PrincipalContext' -ParameterFilter { $Credential -eq $script:testCredential } + } + + Mock -CommandName 'Find-Principal' -MockWith { } + + It 'Should throw if principal cannot be found' { + $errorMessage = ($script:localizedData.CouldNotFindPrincipal -f $script:testUserPrincipal1.Name) + + { $convertToPrincipalResult = ConvertTo-Principal ` + -MemberName $script:testUserPrincipal1.Name ` + -PrincipalContextCache $principalContextCache ` + -Disposables $disposables } | Should Throw $errorMessage + } + } + + Context 'Resolve-SidToPrincipal' { + Mock -CommandName 'Find-Principal' -MockWith { } + Mock -CommandName 'Test-IsLocalMachine' -MockWith { return $true } + + $testSidValue = 'S-1-0-0' + $testSid = New-Object -TypeName 'System.Security.Principal.SecurityIdentifier' -ArgumentList @( $testSidValue ) + + $sidIdentityType = [System.DirectoryServices.AccountManagement.IdentityType]::Sid + + It 'Should throw when principal not found and scope is local' { + { Resolve-SidToPrincipal -Sid $testSid -PrincipalContext $script:testPrincipalContext -Scope $script:localDomain } | Should Throw ($script:localizedData.CouldNotFindPrincipal -f $testSidValue) + + Assert-MockCalled -CommandName 'Find-Principal' -ParameterFilter { $PrincipalContext -eq $script:testPrincipalContext -and $IdentityType -eq $sidIdentityType -and $IdentityValue -eq $testSidValue } + Assert-MockCalled -CommandName 'Test-IsLocalMachine' -ParameterFilter { $Scope -eq $script:localDomain } + } + + Mock -CommandName 'Test-IsLocalMachine' -MockWith { return $false } + + It 'Should throw when principal not found and scope is custom' { + $customDomain = 'CustomDomain' + + { Resolve-SidToPrincipal -Sid $testSid -PrincipalContext $script:testPrincipalContext -Scope $customDomain } | Should Throw ($script:localizedData.CouldNotFindPrincipal -f $testSidValue) + + Assert-MockCalled -CommandName 'Find-Principal' -ParameterFilter { $PrincipalContext -eq $script:testPrincipalContext -and $IdentityType -eq $sidIdentityType -and $IdentityValue -eq $testSidValue } + Assert-MockCalled -CommandName 'Test-IsLocalMachine' -ParameterFilter { $Scope -eq $customDomain } + } + + $fakePrincipal = 'FakePrincipal' + Mock -CommandName 'Find-Principal' -MockWith { return $fakePrincipal } + + It 'Should return found principal' { + Resolve-SidToPrincipal -Sid $testSid -PrincipalContext $script:testPrincipalContext -Scope $script:localDomain | Should Be $fakePrincipal + Assert-MockCalled -CommandName 'Find-Principal' -ParameterFilter { $PrincipalContext -eq $script:testPrincipalContext -and $IdentityType -eq $sidIdentityType -and $IdentityValue -eq $testSidValue } + } + } + + Context 'Get-PrincipalContext' { + $fakePrincipalContext = 'FakePrincipalContext' + + Mock -CommandName 'Test-IsLocalMachine' -MockWith { return $true } + Mock -CommandName 'New-Object' -ParameterFilter { $TypeName -eq 'System.DirectoryServices.AccountManagement.PrincipalContext' } -MockWith { $fakePrincipalContext } + + $localMachineContext = [System.DirectoryServices.AccountManagement.ContextType]::Machine + $customDomainContext = [System.DirectoryServices.AccountManagement.ContextType]::Domain + + It 'Should create a new local principal context' { + $principalContextCache = @{} + $disposables = New-Object -TypeName 'System.Collections.ArrayList' + + $localScope = 'localhost' + + Get-PrincipalContext -Scope $localScope -PrincipalContextCache $principalContextCache -Disposables $disposables + + Assert-MockCalled -CommandName 'Test-IsLocalMachine' -ParameterFilter { $Scope -eq $localScope } + Assert-MockCalled -CommandName 'New-Object' -ParameterFilter { $TypeName -eq 'System.DirectoryServices.AccountManagement.PrincipalContext' -and $ArgumentList.Contains($localMachineContext) } + + $principalContextCache.ContainsKey($localScope) | Should Be $false + $principalContextCache.$script:localDomain | Should Be $fakePrincipalContext + $disposables.Contains($fakePrincipalContext) | Should Be $true + } + + It 'Should return the local principal context from the cache' { + $principalContextCache = @{ $script:localDomain = $script:testPrincipalContext } + $disposables = New-Object -TypeName 'System.Collections.ArrayList' + + Get-PrincipalContext -Scope $script:localDomain -PrincipalContextCache $principalContextCache -Disposables $disposables + + Assert-MockCalled -CommandName 'Test-IsLocalMachine' -ParameterFilter { $Scope -eq $script:localDomain } + Assert-MockCalled -CommandName 'New-Object' -ParameterFilter { $TypeName -eq 'System.DirectoryServices.AccountManagement.PrincipalContext' } -Times 0 -Scope 'It' + + $principalContextCache.$script:localDomain | Should Not Be $fakePrincipalContext + $disposables.Contains($fakePrincipalContext) | Should Be $false + } + + Mock -CommandName 'Test-IsLocalMachine' -MockWith { return $false } + + It 'Should create a new custom principal context without a Credential' { + $principalContextCache = @{} + $disposables = New-Object -TypeName 'System.Collections.ArrayList' + + $customDomain = 'CustomDomain' + + Get-PrincipalContext -Scope $customDomain -PrincipalContextCache $principalContextCache -Disposables $disposables + + Assert-MockCalled -CommandName 'Test-IsLocalMachine' -ParameterFilter { $Scope -eq $customDomain } + + $principalContextArgumentList = @($customDomainContext, $customDomain) + + Assert-MockCalled -CommandName 'New-Object' -ParameterFilter { $TypeName -eq 'System.DirectoryServices.AccountManagement.PrincipalContext' -and + (Compare-Object -ReferenceObject $principalContextArgumentList -DifferenceObject $ArgumentList) -eq $null } + + $principalContextCache.$customDomain | Should Be $fakePrincipalContext + $disposables.Contains($fakePrincipalContext) | Should Be $true + } + + It 'Should create a new custom principal context with a Credential without a domain' { + $principalContextCache = @{} + $disposables = New-Object -TypeName 'System.Collections.ArrayList' + + $customDomain = 'CustomDomain' + + Get-PrincipalContext -Scope $customDomain -Credential $script:testCredential -PrincipalContextCache $principalContextCache -Disposables $disposables + + Assert-MockCalled -CommandName 'Test-IsLocalMachine' -ParameterFilter { $Scope -eq $customDomain } + + $principalContextArgumentList = @( $customDomainContext, $customDomain, $script:testCredential.GetNetworkCredential().UserName, $script:testCredential.GetNetworkCredential().Password ) + + Assert-MockCalled -CommandName 'New-Object' -ParameterFilter { $TypeName -eq 'System.DirectoryServices.AccountManagement.PrincipalContext' -and + (Compare-Object -ReferenceObject $principalContextArgumentList -DifferenceObject $ArgumentList) -eq $null } + + $principalContextCache.$customDomain | Should Be $fakePrincipalContext + $disposables.Contains($fakePrincipalContext) | Should Be $true + } + + It 'Should create a new custom principal context with a Credential with a domain' { + $principalContextCache = @{} + $disposables = New-Object -TypeName 'System.Collections.ArrayList' + + $customDomain = 'CustomDomain' + + $userNameWithDomain = 'CustomDomain\username' + $testPassword = 'TestPassword' + $secureTestPassword = ConvertTo-SecureString -String $testPassword -AsPlainText -Force + + $credentialWithDomain = New-Object -TypeName 'System.Management.Automation.PSCredential' -ArgumentList @( $userNameWithDomain, $secureTestPassword ) + + Get-PrincipalContext -Scope $customDomain -Credential $credentialWithDomain -PrincipalContextCache $principalContextCache -Disposables $disposables + + Assert-MockCalled -CommandName 'Test-IsLocalMachine' -ParameterFilter { $Scope -eq $customDomain } + + $principalContextArgumentList = @( $customDomainContext, $customDomain, $userNameWithDomain, $credentialWithDomain.GetNetworkCredential().Password ) + + Assert-MockCalled -CommandName 'New-Object' -ParameterFilter { $TypeName -eq 'System.DirectoryServices.AccountManagement.PrincipalContext' -and + (Compare-Object -ReferenceObject $principalContextArgumentList -DifferenceObject $ArgumentList) -eq $null } + + $principalContextCache.$customDomain | Should Be $fakePrincipalContext + $disposables.Contains($fakePrincipalContext) | Should Be $true + } + + It 'Should return a custom principal context from the cache' { + $customDomain = 'CustomDomain' + + $principalContextCache = @{ $customDomain = $script:testPrincipalContext } + $disposables = New-Object -TypeName 'System.Collections.ArrayList' + + Get-PrincipalContext -Scope $customDomain -PrincipalContextCache $principalContextCache -Disposables $disposables + + Assert-MockCalled -CommandName 'Test-IsLocalMachine' -ParameterFilter { $Scope -eq $customDomain } + Assert-MockCalled -CommandName 'New-Object' -ParameterFilter { $TypeName -eq 'System.DirectoryServices.AccountManagement.PrincipalContext' } -Times 0 -Scope 'It' + + $principalContextCache.$customDomain | Should Not Be $fakePrincipalContext + $disposables.Contains($fakePrincipalContext) | Should Be $false + } + } + } + + + } + } +} +finally +{ + Exit-DscResourceTestEnvironment -TestEnvironment $script:testEnvironment +}