diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c2efcb..408487c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -177,6 +177,30 @@ All notable changes to this module are recorded here. Format follows round trip. The batching stays because it is the right shape and it is what a faster realm would reward; the number is what the lab measured. +- **`Compare-TestEnvironment` compares the people two connected providers hold, the way a + hybrid identity match would.** The seed puts the same people into every directory it knows so + that identity matching across two of them can be tested; this reads the seeded people from two + providers connected in the same session and reports how they line up. People are matched by login + key first - the login with the provider's additions stripped, so jnino is jnino everywhere the + shared logins are kept - and then by display name, folded for case and Unicode normalisation, + because the Active Directory data logs its people in as first name and initial and agrees with the + others only on the names. What matches in neither way is reported as only on one side and is not a + fault, because the providers hold deliberately different populations. For every matched pair the + names are compared by codepoint - display names where both providers keep one, given name and + surname where both keep only those, never a stored display name against a composed one - and a + name that differs is the one finding the command judges; a decomposed José in one directory and + a precomposed one in the other is found by the folded match and then reported as differing. The + enabled state is compared and reported without a verdict, because the seed hangs different states + on the same person on purpose. Each provider implements `Get-IdentitySnapshot`, and a + contract test holds every provider folder on disk to it. + + Verified live from one session against the lab tenant and the PingOne sandbox, seeded together: + 329 people matched by login key, none left to match by name, 328 names compared as given name + and surname with none differing, one account only in PingOne, no enabled state differing, and + the same answer with the providers the other way round. The tenant compared on the parts + because the environment stores no display name, which is why `Get-EntraSeededObject` now selects + the given name and surname. + ### Changed - **The Entra report takes the shared parameters.** `-Format` and `-Path` are kept as aliases of diff --git a/CLAUDE.md b/CLAUDE.md index 63da3c4..d4cb8cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -310,6 +310,13 @@ judged on what is missing only, because dynamic groups, AD group rules and FreeI add members the data never lists. The result shape is built only by `New-TestEnvironmentCheck` and `New-TestEnvironmentVerification` in `Core/`, so one renderer prints every provider. +`Compare-TestEnvironment` is the cross-provider half: each provider's `Get-IdentitySnapshot` +reduces the users teardown would find to a `TestIdentity` whose `Key` is the login with that +provider's additions stripped, and `Compare-TestIdentitySnapshot` matches by key, then by display +name folded for case and Unicode normalisation, and judges only the codepoint equality of the +matched names. A display name is never compared against a name composed from parts; PingOne stores +no display name and a composed one would put the family name last for the Han and Japanese people. + ### The SecretStore is shared per user, not per module SecretStore configuration is per user. One machine has one store shared by everything that diff --git a/Core/Compare-TestIdentitySnapshot.ps1 b/Core/Compare-TestIdentitySnapshot.ps1 new file mode 100644 index 0000000..e1b98af --- /dev/null +++ b/Core/Compare-TestIdentitySnapshot.ps1 @@ -0,0 +1,144 @@ +function Compare-TestIdentitySnapshot { + <# + .SYNOPSIS + Compares the people two providers hold, by login key and then by name + .DESCRIPTION + The comparison behind Compare-TestEnvironment, kept apart from the reading so it can be + tested on identities alone. Each side is a list of identities as Get-IdentitySnapshot + returns them: a Key, which is the login with whatever the provider added stripped off - the + seed prefix, the UPN suffix, the email domain - so that jnino is jnino in every provider that + keeps the shared logins; a DisplayName where the provider has one; GivenName and Surname where + it has those; and Enabled. + + Matching is by Key first, then by DisplayName among what is left, because Active Directory's + data logs its people in as first name and initial while every other provider uses the shared + keys, and the two agree only on the names. What matches in neither way is reported as only on + one side. That is not a fault: the providers deliberately hold different populations, and the + report says so rather than judging it. + + For the pairs that matched, the names are compared by codepoint, never with -eq, which is + what hybrid identity matching across two directories depends on: the display names where both + sides have one, otherwise the given name and surname where both have those, otherwise nothing. + A stored display name is never compared against one composed from parts. + Enabled is compared and reported without a verdict, because the seed hangs different states on + the same person in different providers on purpose. The verdict is the names alone. + .PARAMETER Left + The first provider's identities + .PARAMETER Right + The second provider's identities + .OUTPUTS + PSCustomObject typed TestEnvironmentComparison, with the two sides, MatchedByKey, + MatchedByName, OnlyLeft, OnlyRight, NameMismatch, StateDifference and Passed + .EXAMPLE + PS> Compare-TestIdentitySnapshot -Left $entra -Right $pingOne + + Matches 329 people by key, reports the one PingOne-only account, and passes when every + matched pair's names agree. + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter(Mandatory = $true)] + [ValidateNotNull()] + [PSObject]$Left, + + [Parameter(Mandatory = $true)] + [ValidateNotNull()] + [PSObject]$Right + ) + + $leftIdentities = @($Left.Identities | Where-Object { $null -ne $_ }) + $rightIdentities = @($Right.Identities | Where-Object { $null -ne $_ }) + + $describe = { param($identity) if ($identity.DisplayName) { '{0} ({1})' -f $identity.Key, $identity.DisplayName } else { $identity.Key } } + + # By key first. + $rightByKey = @{} + foreach ($identity in $rightIdentities) { + if ($identity.Key -and -not $rightByKey.ContainsKey($identity.Key)) { $rightByKey[[string]$identity.Key] = $identity } + } + $pairs = New-Object System.Collections.Generic.List[object] + $matchedRight = New-Object 'System.Collections.Generic.HashSet[object]' + $unmatchedLeft = New-Object System.Collections.Generic.List[object] + foreach ($identity in $leftIdentities) { + if ($identity.Key -and $rightByKey.ContainsKey($identity.Key)) { + $other = $rightByKey[[string]$identity.Key] + $pairs.Add([PSCustomObject]@{ Left = $identity; Right = $other; By = 'Key' }) + $null = $matchedRight.Add($other) + } + else { $unmatchedLeft.Add($identity) } + } + + # Then by display name among what is left on both sides. Matched on the normalised, + # case-folded form, so that a name one directory stored decomposed still finds its person + # and the codepoint difference is then reported as the finding it is, rather than the two + # spellings passing as two people who happen to be missing from each other's directory. + $fold = { param($name) ([string]$name).Normalize([System.Text.NormalizationForm]::FormC).ToLowerInvariant() } + $rightByName = @{} + foreach ($identity in $rightIdentities) { + if ($matchedRight.Contains($identity)) { continue } + if (-not $identity.DisplayName) { continue } + $name = & $fold $identity.DisplayName + if (-not $rightByName.ContainsKey($name)) { $rightByName[$name] = $identity } + } + $onlyLeft = New-Object System.Collections.Generic.List[string] + foreach ($identity in $unmatchedLeft) { + $name = if ($identity.DisplayName) { & $fold $identity.DisplayName } else { '' } + if ($name -and $rightByName.ContainsKey($name) -and -not $matchedRight.Contains($rightByName[$name])) { + $other = $rightByName[$name] + $pairs.Add([PSCustomObject]@{ Left = $identity; Right = $other; By = 'Name' }) + $null = $matchedRight.Add($other) + } + else { $onlyLeft.Add((& $describe $identity)) } + } + $onlyRight = @($rightIdentities | Where-Object { -not $matchedRight.Contains($_) } | ForEach-Object { & $describe $_ }) + + $nameMismatch = New-Object System.Collections.Generic.List[string] + $stateDifference = New-Object System.Collections.Generic.List[string] + $namesCompared = 0 + foreach ($pair in $pairs) { + # Only the same kind of name against the same kind: display names where both sides keep + # one, otherwise the given name and surname where both keep those. A stored display name + # is never compared against one composed from parts, which would call every + # family-name-first person a mismatch. + $leftName = $null + $rightName = $null + if ($pair.Left.DisplayName -and $pair.Right.DisplayName) { + $leftName = [string]$pair.Left.DisplayName + $rightName = [string]$pair.Right.DisplayName + } + elseif (($pair.Left.GivenName -or $pair.Left.Surname) -and ($pair.Right.GivenName -or $pair.Right.Surname)) { + $leftName = ('{0} {1}' -f $pair.Left.GivenName, $pair.Left.Surname).Trim() + $rightName = ('{0} {1}' -f $pair.Right.GivenName, $pair.Right.Surname).Trim() + } + if ($null -ne $leftName) { + $namesCompared++ + if (-not [string]::Equals($leftName, $rightName, [StringComparison]::Ordinal)) { + $nameMismatch.Add(("{0}: {1} has '{2}', {3} has '{4}'" -f $pair.Left.Key, $Left.Provider, $leftName, $Right.Provider, $rightName)) + } + } + if ($null -ne $pair.Left.Enabled -and $null -ne $pair.Right.Enabled -and ([bool]$pair.Left.Enabled) -ne ([bool]$pair.Right.Enabled)) { + $state = { param($enabled) if ($enabled) { 'enabled' } else { 'disabled' } } + $stateDifference.Add(("{0}: {1} in {2}, {3} in {4}" -f $pair.Left.Key, (& $state $pair.Left.Enabled), $Left.Provider, (& $state $pair.Right.Enabled), $Right.Provider)) + } + } + + $sort = { param($list) $array = [string[]]@($list); [Array]::Sort($array, [System.StringComparer]::Ordinal); $array } + + return [PSCustomObject]@{ + PSTypeName = 'TestEnvironmentComparison' + Left = [PSCustomObject]@{ Provider = $Left.Provider; Target = $Left.Target; Count = $leftIdentities.Count } + Right = [PSCustomObject]@{ Provider = $Right.Provider; Target = $Right.Target; Count = $rightIdentities.Count } + ComparedOn = Get-Date + Matched = $pairs.Count + MatchedByKey = @($pairs | Where-Object { $_.By -eq 'Key' }).Count + MatchedByName = @($pairs | Where-Object { $_.By -eq 'Name' }).Count + NamesCompared = $namesCompared + # Typed, so a list of one comes back as a list of one rather than as a string. + OnlyLeft = [string[]]@(& $sort $onlyLeft) + OnlyRight = [string[]]@(& $sort $onlyRight) + NameMismatch = [string[]]@(& $sort $nameMismatch) + StateDifference = [string[]]@(& $sort $stateDifference) + Passed = ($nameMismatch.Count -eq 0) + } +} diff --git a/Core/New-TestIdentity.ps1 b/Core/New-TestIdentity.ps1 new file mode 100644 index 0000000..499d8c3 --- /dev/null +++ b/Core/New-TestIdentity.ps1 @@ -0,0 +1,82 @@ +function New-TestIdentity { + <# + .SYNOPSIS + Shapes one seeded person the way Compare-TestEnvironment reads them, whichever provider held them + .DESCRIPTION + Every Get-IdentitySnapshot returns a list of these, so the comparison never has to + know where a person came from. The Key is the login with whatever the provider added + stripped off, lower-cased: the seed prefix from a PingOne username, the UPN suffix from an + Entra one, the email domain from an Okta login. DisplayName is $null where the provider + keeps no such field, which is how the comparison knows to fall back to the given name and + surname rather than compose a name the provider never stored. + .PARAMETER Provider + The provider the person was read from + .PARAMETER Login + The login as the provider holds it + .PARAMETER Key + The login with the provider's additions stripped; defaults to the lower-cased login + .PARAMETER DisplayName + The display name as stored, or nothing when the provider has no such field + .PARAMETER GivenName + The given name as stored, where the provider has one + .PARAMETER Surname + The surname as stored, where the provider has one + .PARAMETER Enabled + Whether the account can sign in + .OUTPUTS + PSCustomObject typed TestIdentity + .EXAMPLE + PS> New-TestIdentity -Provider Entra -Login 'ZZ-TEST-jnino@lab.example.com' -Key 'jnino' -DisplayName $user.displayName -Enabled $user.accountEnabled + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Builds an object in memory and changes nothing outside it.')] + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$Provider, + + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [string]$Login, + + [Parameter()] + [AllowEmptyString()] + [string]$Key, + + [Parameter()] + [AllowNull()] + [AllowEmptyString()] + [string]$DisplayName, + + [Parameter()] + [AllowNull()] + [AllowEmptyString()] + [string]$GivenName, + + [Parameter()] + [AllowNull()] + [AllowEmptyString()] + [string]$Surname, + + [Parameter()] + [AllowNull()] + [object]$Enabled + ) + + if (-not $Key) { $Key = $Login } + $enabledValue = $null + if ($null -ne $Enabled) { $enabledValue = [bool]$Enabled } + + return [PSCustomObject]@{ + PSTypeName = 'TestIdentity' + Provider = $Provider + Key = $Key.ToLowerInvariant() + Login = $Login + DisplayName = $(if ($DisplayName) { $DisplayName } else { $null }) + GivenName = $(if ($GivenName) { $GivenName } else { $null }) + Surname = $(if ($Surname) { $Surname } else { $null }) + Enabled = $enabledValue + } +} diff --git a/Providers/AD/Private/Get-ADIdentitySnapshot.ps1 b/Providers/AD/Private/Get-ADIdentitySnapshot.ps1 new file mode 100644 index 0000000..91f2556 --- /dev/null +++ b/Providers/AD/Private/Get-ADIdentitySnapshot.ps1 @@ -0,0 +1,34 @@ +function Get-ADIdentitySnapshot { + <# + .SYNOPSIS + Reads the seeded users of the domain as identities Compare-TestEnvironment can match + .DESCRIPTION + The users under the seed OU that carry the tag, the way teardown finds them. The key is + the SAM account name, which in this data is first name and initial rather than the shared + login the other providers use, so a comparison against another provider matches most of + these people by display name; the display name is the one the seed wrote, from the same + shared file as everywhere else. + .OUTPUTS + PSCustomObject with Provider, Target and Identities + .EXAMPLE + PS> (Get-ADIdentitySnapshot).Identities.Count + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param() + + $domain = Get-ADTestDomain + $seedRoot = "OU=$($script:ADTestRootName),$($domain.DomainDN)" + $identities = @() + # A search base that does not exist is an error to the AD cmdlets rather than an empty result. + $rootExists = @(Get-ADOrganizationalUnit -Filter "Name -eq '$($script:ADTestRootName)'" -SearchBase $domain.DomainDN ` + -SearchScope OneLevel -ErrorAction Stop).Count -gt 0 + if ($rootExists) { + $identities = foreach ($user in @(Get-ADUser -Filter '*' -SearchBase "OU=Users,$seedRoot" -Properties adminDescription, DisplayName, GivenName, Surname -ErrorAction Stop | + Select-ADTestOwnedObject -Kind user)) { + New-TestIdentity -Provider 'AD' -Login ([string]$user.SamAccountName) -DisplayName ([string]$user.DisplayName) ` + -GivenName ([string]$user.GivenName) -Surname ([string]$user.Surname) -Enabled $user.Enabled + } + } + [PSCustomObject]@{ Provider = 'AD'; Target = $domain.DNSName; Identities = @($identities) } +} diff --git a/Providers/Authentik/Private/Get-AuthentikIdentitySnapshot.ps1 b/Providers/Authentik/Private/Get-AuthentikIdentitySnapshot.ps1 new file mode 100644 index 0000000..3a73a78 --- /dev/null +++ b/Providers/Authentik/Private/Get-AuthentikIdentitySnapshot.ps1 @@ -0,0 +1,24 @@ +function Get-AuthentikIdentitySnapshot { + <# + .SYNOPSIS + Reads the seeded users of the instance as identities Compare-TestEnvironment can match + .DESCRIPTION + The users teardown would find, the module's own service account left out. The key is the + username as stored, which the seed never prefixes, so it is the shared login. Authentik + keeps one name field, so the display name is compared and there is no given name or + surname to fall back to. + .OUTPUTS + PSCustomObject with Provider, Target and Identities + .EXAMPLE + PS> (Get-AuthentikIdentitySnapshot).Identities.Count + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param() + + $connection = Get-AuthentikConnection + $identities = foreach ($user in @(Get-AuthentikSeededObject -Type Users -Connection $connection)) { + New-TestIdentity -Provider 'Authentik' -Login ([string]$user.username) -DisplayName ([string]$user.name) -Enabled $user.is_active + } + [PSCustomObject]@{ Provider = 'Authentik'; Target = $connection.BaseUrl; Identities = @($identities) } +} diff --git a/Providers/Entra/Private/Get-EntraIdentitySnapshot.ps1 b/Providers/Entra/Private/Get-EntraIdentitySnapshot.ps1 new file mode 100644 index 0000000..dd9139d --- /dev/null +++ b/Providers/Entra/Private/Get-EntraIdentitySnapshot.ps1 @@ -0,0 +1,30 @@ +function Get-EntraIdentitySnapshot { + <# + .SYNOPSIS + Reads the seeded members of the tenant as identities Compare-TestEnvironment can match + .DESCRIPTION + The users teardown would find, guests left out because an invited guest's login is + minted by Entra and matches nothing anywhere else. The key is the UPN's local part with + the seed prefix stripped, so ZZ-TEST-jnino@lab.example.com is jnino, which is what every + other provider that keeps the shared logins calls the same person. + .OUTPUTS + PSCustomObject with Provider, Target and Identities + .EXAMPLE + PS> (Get-EntraIdentitySnapshot).Identities.Count + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param() + + $connection = Get-EntraConnection + $marker = Get-EntraSeedMarker -Connection $connection + $identities = foreach ($user in @(Get-EntraSeededObject -Type Users -Connection $connection)) { + if ($user.userType -eq 'Guest') { continue } + $login = [string]$user.userPrincipalName + $local = ($login -split '@', 2)[0] + if ($local.StartsWith($marker.Prefix, [StringComparison]::OrdinalIgnoreCase)) { $local = $local.Substring($marker.Prefix.Length) } + New-TestIdentity -Provider 'Entra' -Login $login -Key $local -DisplayName ([string]$user.displayName) ` + -GivenName ([string]$user.givenName) -Surname ([string]$user.surname) -Enabled $user.accountEnabled + } + [PSCustomObject]@{ Provider = 'Entra'; Target = $connection.TenantId; Identities = @($identities) } +} diff --git a/Providers/Entra/Private/Get-EntraSeededObject.ps1 b/Providers/Entra/Private/Get-EntraSeededObject.ps1 index b3885ab..bfb795d 100644 --- a/Providers/Entra/Private/Get-EntraSeededObject.ps1 +++ b/Providers/Entra/Private/Get-EntraSeededObject.ps1 @@ -84,7 +84,7 @@ # for the types that cannot belong to one. $shape = @{ Users = @{ UnitKey = 'Users'; Graph = 'user'; Path = '/users' - Select = 'id,displayName,userPrincipalName,accountEnabled,userType,externalUserState,mail,department,jobTitle,usageLocation,employeeId,employeeType,companyName,onPremisesExtensionAttributes,assignedLicenses,createdDateTime' + Select = 'id,displayName,givenName,surname,userPrincipalName,accountEnabled,userType,externalUserState,mail,department,jobTitle,usageLocation,employeeId,employeeType,companyName,onPremisesExtensionAttributes,assignedLicenses,createdDateTime' } Groups = @{ UnitKey = 'Groups'; Graph = 'group'; Path = '/groups' Select = 'id,displayName,description,mailNickname,groupTypes,securityEnabled,mailEnabled,membershipRule,isAssignableToRole,assignedLicenses,createdDateTime' diff --git a/Providers/FreeIPA/Private/Get-FreeIPAIdentitySnapshot.ps1 b/Providers/FreeIPA/Private/Get-FreeIPAIdentitySnapshot.ps1 new file mode 100644 index 0000000..72857ef --- /dev/null +++ b/Providers/FreeIPA/Private/Get-FreeIPAIdentitySnapshot.ps1 @@ -0,0 +1,34 @@ +function Get-FreeIPAIdentitySnapshot { + <# + .SYNOPSIS + Reads the seeded active users of the realm as identities Compare-TestEnvironment can match + .DESCRIPTION + The active users teardown would find, read with their detail so the display name and the + lock state come back; staged and preserved users are left out, because neither can sign + in anywhere and neither exists in the other providers as such. The key is the uid, which + the seed never prefixes, so it is the shared login. Enabled is the inverse of nsaccountlock. + .OUTPUTS + PSCustomObject with Provider, Target and Identities + .EXAMPLE + PS> (Get-FreeIPAIdentitySnapshot).Identities.Count + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param() + + $connection = Get-FreeIPAConnection + # FreeIPA returns every attribute as a list, even a single-valued one. + $first = { + param($value) + if ($value -is [array]) { if ($value.Count -gt 0) { [string]$value[0] } else { '' } } + elseif ($null -eq $value) { '' } + else { [string]$value } + } + $identities = foreach ($user in @(Get-FreeIPASeededObject -Type Users -Detail -Connection $connection)) { + $locked = $false + if ($user.PSObject.Properties['nsaccountlock']) { $locked = ((& $first $user.nsaccountlock) -eq 'True') } + New-TestIdentity -Provider 'FreeIPA' -Login (& $first $user.uid) -DisplayName (& $first $user.displayname) ` + -GivenName (& $first $user.givenname) -Surname (& $first $user.sn) -Enabled (-not $locked) + } + [PSCustomObject]@{ Provider = 'FreeIPA'; Target = $connection.BaseUrl; Identities = @($identities) } +} diff --git a/Providers/Okta/Private/Get-OktaIdentitySnapshot.ps1 b/Providers/Okta/Private/Get-OktaIdentitySnapshot.ps1 new file mode 100644 index 0000000..76b17be --- /dev/null +++ b/Providers/Okta/Private/Get-OktaIdentitySnapshot.ps1 @@ -0,0 +1,25 @@ +function Get-OktaIdentitySnapshot { + <# + .SYNOPSIS + Reads the seeded users of the org as identities Compare-TestEnvironment can match + .DESCRIPTION + The users teardown would find. The key is the login's local part, jnino from + jnino@oktalab.example.com, which is the shared login every other provider keeps. Enabled + is whether the status is ACTIVE; a suspended, staged or deprovisioned user is not. + .OUTPUTS + PSCustomObject with Provider, Target and Identities + .EXAMPLE + PS> (Get-OktaIdentitySnapshot).Identities.Count + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param() + + $connection = Get-OktaConnection + $identities = foreach ($user in @(Get-OktaSeededUser -Prefix $connection.Prefix -EmailDomain $connection.EmailDomain)) { + $login = [string]$user.profile.login + New-TestIdentity -Provider 'Okta' -Login $login -Key (($login -split '@', 2)[0]) -DisplayName ([string]$user.profile.displayName) ` + -GivenName ([string]$user.profile.firstName) -Surname ([string]$user.profile.lastName) -Enabled ([string]$user.status -eq 'ACTIVE') + } + [PSCustomObject]@{ Provider = 'Okta'; Target = $connection.OrgUrl; Identities = @($identities) } +} diff --git a/Providers/PingOne/Private/Get-PingOneIdentitySnapshot.ps1 b/Providers/PingOne/Private/Get-PingOneIdentitySnapshot.ps1 new file mode 100644 index 0000000..a6ed09c --- /dev/null +++ b/Providers/PingOne/Private/Get-PingOneIdentitySnapshot.ps1 @@ -0,0 +1,29 @@ +function Get-PingOneIdentitySnapshot { + <# + .SYNOPSIS + Reads the seeded users of the environment as identities Compare-TestEnvironment can match + .DESCRIPTION + The users teardown would find. The key is the username with the seed prefix stripped, so + zz-test-jnino is jnino, the shared login. PingOne keeps a given name and a family name and + no display name, so the display name is left empty and the comparison falls back to the + parts rather than composing a name the environment never stored: a composed one would put + the family name last for a person whose name puts it first. + .OUTPUTS + PSCustomObject with Provider, Target and Identities + .EXAMPLE + PS> (Get-PingOneIdentitySnapshot).Identities.Count + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param() + + $connection = Get-PingOneConnection + $marker = Get-PingOneSeedMarker -Prefix $connection.Prefix + $identities = foreach ($user in @(Get-PingOneSeededObject -Type Users -Connection $connection)) { + $login = [string]$user.username + $key = $login + if ($key.StartsWith($marker.Prefix, [StringComparison]::OrdinalIgnoreCase)) { $key = $key.Substring($marker.Prefix.Length) } + New-TestIdentity -Provider 'PingOne' -Login $login -Key $key -GivenName ([string]$user.name.given) -Surname ([string]$user.name.family) -Enabled $user.enabled + } + [PSCustomObject]@{ Provider = 'PingOne'; Target = $connection.EnvironmentId; Identities = @($identities) } +} diff --git a/Public/Compare-TestEnvironment.ps1 b/Public/Compare-TestEnvironment.ps1 new file mode 100644 index 0000000..107100d --- /dev/null +++ b/Public/Compare-TestEnvironment.ps1 @@ -0,0 +1,69 @@ +function Compare-TestEnvironment { + <# + .EXTERNALHELP TestEnvironment-Help.xml + .SYNOPSIS + Compares the people two connected providers hold, the way a hybrid identity match would + #> + + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter(Mandatory = $true, Position = 0)] + [ValidateCount(2, 2)] + [string[]]$Provider, + + [Parameter()] + [switch]$Quiet + ) + + $names = @($Provider | ForEach-Object { [string]$_ }) + foreach ($name in $names) { + if (-not $script:TestEnvironmentProvider.ContainsKey($name)) { + Write-Error ("No provider called '$name' is loaded. Available: " + + (($script:TestEnvironmentProvider.Keys | Sort-Object) -join ', ')) -ErrorAction Stop + return + } + } + if ($names[0] -eq $names[1]) { + Write-Error 'Name two different providers to compare.' -ErrorAction Stop + return + } + + $snapshots = foreach ($name in $names) { + $reader = 'Get-{0}IdentitySnapshot' -f $name + if (-not (Get-Command -Name $reader -ErrorAction SilentlyContinue)) { + Write-Error "The $name provider does not implement $reader." -ErrorAction Stop + return + } + try { & $reader } + catch { + throw (New-Object System.Exception( + "Could not read the seeded people from $name. Both providers must be connected in this session: $($_.Exception.Message)", $_.Exception)) + } + } + + $result = Compare-TestIdentitySnapshot -Left $snapshots[0] -Right $snapshots[1] + + if (-not $Quiet) { + Write-TestMessage -Message "Comparing the seeded people of $($result.Left.Provider) and $($result.Right.Provider)" -Type Header + Write-TestMessage -Message ('{0}: {1} people in {2}' -f $result.Left.Provider, $result.Left.Count, $result.Left.Target) -Type Info + Write-TestMessage -Message ('{0}: {1} people in {2}' -f $result.Right.Provider, $result.Right.Count, $result.Right.Target) -Type Info + Write-TestMessage -Message ('Matched {0}: {1} by login key, {2} by display name' -f $result.Matched, $result.MatchedByKey, $result.MatchedByName) -Type Info + foreach ($side in @(@{ Label = "Only in $($result.Left.Provider)"; Items = $result.OnlyLeft }, @{ Label = "Only in $($result.Right.Provider)"; Items = $result.OnlyRight })) { + $count = @($side.Items).Count + if ($count -eq 0) { Write-TestMessage -Message ('{0}: none' -f $side.Label) -Type Info } + else { Write-TestMessage -Message ('{0}: {1} ({2})' -f $side.Label, $count, (Format-TestEnvironmentSample -Item $side.Items)) -Type Info } + } + if (@($result.StateDifference).Count -gt 0) { + Write-TestMessage -Message ('Enabled differs for {0}, which the seed does on purpose ({1})' -f @($result.StateDifference).Count, (Format-TestEnvironmentSample -Item $result.StateDifference -Limit 3)) -Type Info + } + if ($result.Passed) { + Write-TestMessage -Message ('Names agree by codepoint for all {0} matched people whose names both sides hold.' -f $result.NamesCompared) -Type Success + } + else { + Write-TestMessage -Message ('Names differ for {0} of {1} matched people: {2}' -f @($result.NameMismatch).Count, $result.NamesCompared, (Format-TestEnvironmentSample -Item $result.NameMismatch -Limit 3)) -Type Error + } + } + + return $result +} diff --git a/README.md b/README.md index 1accef9..7238077 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ page covers what every provider shares. - ✅ **Idempotent** — a re-run reuses what exists rather than duplicating it - ✅ **One prefix and one tag everywhere** — `ZZ-TEST-` on names and `ZZ-TEST-seed` where the directory can store it, so seeded objects can be found across a hybrid estate with one filter - ✅ **No dependencies** — no SDKs, no gallery installs, works on a stock 5.1 host; the AD provider imports RSAT at connect time and says so when it is absent -- ✅ **Shared people** — the AD, Entra, Authentik, FreeIPA and PingOne providers seed the same people, so hybrid identity matching is testable +- ✅ **Shared people** — the AD, Entra, Authentik, FreeIPA and PingOne providers seed the same people, so hybrid identity matching is testable, and `Compare-TestEnvironment` proves two of them agree - ✅ **Verifiable** — `Test-TestEnvironment` checks the seeded estate against the seed data and names what is missing, what is extra and which name came back wrong ## 📦 Installation @@ -137,6 +137,7 @@ the real command rather than from a pass-through that accepts anything. - **`Remove-TestEnvironment`** — teardown, proving ownership before deleting - **`Get-TestEnvironmentReport`** — Console, JSON, CSV or HTML, with the same `-OutputFormat`, `-OutputPath` and `-PassThru` and the same report shape for every provider - **`Test-TestEnvironment`** — compares the directory with the seed data: every object present and found the way teardown finds it, nothing extra, every name equal by codepoint, every membership in place; one result object for every provider +- **`Compare-TestEnvironment`** — compares the people two connected providers hold, by login key and then by name, and judges the one thing a hybrid identity match trips over: a name that differs by codepoint between two directories - **`Update-TestContainment`** — reconciles container membership where the provider has containers ### Components diff --git a/TestEnvironment.psd1 b/TestEnvironment.psd1 index f1fcf37..1493643 100644 --- a/TestEnvironment.psd1 +++ b/TestEnvironment.psd1 @@ -32,6 +32,7 @@ 'Remove-TestEnvironment', 'Get-TestEnvironmentReport', 'Test-TestEnvironment', + 'Compare-TestEnvironment', 'Get-TestAccessToken', 'New-TestServiceApp', 'Get-TestServiceApp', diff --git a/TestEnvironment.psm1 b/TestEnvironment.psm1 index 61674d6..410ca8c 100644 --- a/TestEnvironment.psm1 +++ b/TestEnvironment.psm1 @@ -90,6 +90,7 @@ Export-ModuleMember -Function @( 'Remove-TestEnvironment', 'Get-TestEnvironmentReport', 'Test-TestEnvironment', + 'Compare-TestEnvironment', 'Get-TestAccessToken', 'New-TestServiceApp', 'Get-TestServiceApp', diff --git a/Tests/README.md b/Tests/README.md index 69c381b..ca2b295 100644 --- a/Tests/README.md +++ b/Tests/README.md @@ -59,6 +59,9 @@ promise the README makes, or a regression for a bug that reached a real director | `Core\New-TestEnvironmentReport.Tests.ps1` | The one report shape and the one file writer: Provider, Target, GeneratedOn, Counts, Sections and a property per section in order with both type names; JSON as UTF-8 without a byte order mark; one CSV per section with an empty file for an empty section and lists joined rather than written as a type name; HTML with a heading and count per section, encoded, with a marked warning | | `Public\Get-TestEnvironmentReport.Tests.ps1` | The shared surface by contract: every provider's report takes `-OutputFormat` with the same four values, `-OutputPath` and `-PassThru`; every provider that stores a credential answers to `-UseStoredCredential`; the dispatchers forward both the shared names and the Entra aliases | | `Providers\Entra\Get-EntraEnvironmentReport.Tests.ps1`, `Providers\PingOne\Get-PingOneEnvironmentReport.Tests.ps1`, `Providers\AD\Get-ADEnvironmentReport.Tests.ps1` | That each returns the shared shape with -PassThru and nothing without it, writes the three file formats as UTF-8 through the shared writer, refuses a file format with no path, and, for AD, projects objects to the columns shown and reads only those properties under the seed OU | +| `Core\Compare-TestIdentitySnapshot.Tests.ps1` | The comparison on identities alone: matched by key, then by display name folded for case and normalisation, the rest reported as only on one side without failing; matched names compared by codepoint so the decomposed twin `-eq` calls equal is the finding; parts compared against parts and never against a composed name; the enabled state reported without a verdict | +| `Public\Compare-TestEnvironment.Tests.ps1` | That the command reads both providers through their own snapshot readers, refuses an unknown provider and the same one twice, says both must be connected when one cannot be read, prints each side's counts, matches, own people, states and verdict, and that every provider folder on disk has a snapshot reader | +| `Providers\PingOne\Get-PingOneIdentitySnapshot.Tests.ps1` | All six snapshot readers: each reduces the users teardown would find to the same identity, with the key stripped of what the provider added; PingOne leaves the display name absent, FreeIPA reads the lock flag, Entra leaves guests out, Okta calls only ACTIVE enabled, AD reads only tagged users under the seed OU | | `Public\Test-TestEnvironment.Tests.ps1` | That the dispatcher reaches the connected provider's `Test-Environment` with the mirrored parameters bound, refuses an unknown one at binding, and that every provider folder on disk implements that command | | `Providers\\Test-Environment.Tests.ps1`, one per provider | Each verifier against a directory built from the module's own seed files by the rules the seed applies: it passes; then one removed user, one display name swapped for its decomposed twin, one group the data never names and one dropped membership are each named in the result; a member a rule added is forgiven; `-SkipMembership` reads nothing; `-Quiet` prints nothing. The AD suite also pins that a missing seed OU reports everything missing without searching | | `Core\Confirm-TestTeardown.Tests.ps1` | That the one teardown question reaches the cmdlet as written and its answer is returned, and that a host which cannot ask is read as a refusal, never a yes | diff --git a/Tests/Unit/Core/Compare-TestIdentitySnapshot.Tests.ps1 b/Tests/Unit/Core/Compare-TestIdentitySnapshot.Tests.ps1 new file mode 100644 index 0000000..2a070ac --- /dev/null +++ b/Tests/Unit/Core/Compare-TestIdentitySnapshot.Tests.ps1 @@ -0,0 +1,141 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.1.0' } + +<# + The comparison behind Compare-TestEnvironment, on identities alone. What has to hold: a + person is matched by login key first and by display name among what is left, because the + Active Directory data logs its people in as first name and initial and agrees with the + others only on the names; what matches in neither way is reported as only on one side and is + not a failure; matched names are compared by codepoint, so the decomposed José that -eq calls + equal to the precomposed one is the finding; a display name is never compared against a name + composed from parts, because that would call every family-name-first person a mismatch; and + the enabled state is reported without a verdict. +#> + +BeforeAll { + $moduleRoot = (Split-Path -Path (Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent) -Parent) + . (Join-Path $moduleRoot 'Tests\Stubs\Add-ADTestStubPath.ps1') + # Imported once per run, not once per file: a warm forced import costs about 190 ms, and 111 + # files paid it. CI runs the suite shuffled, so state one file leaves behind for another + # fails there rather than hiding in file order. + if (-not (Get-Module TestEnvironment)) { Import-Module (Join-Path $moduleRoot 'TestEnvironment.psd1') } +} + +Describe 'New-TestIdentity' -Tag 'Unit', 'Private' { + + It 'lower-cases the key, defaults it to the login, and keeps an absent name absent rather than empty' { + InModuleScope TestEnvironment { + $identity = New-TestIdentity -Provider 'Okta' -Login 'JNino@oktalab.example.com' -DisplayName '' -Enabled 'true' + $identity.Key | Should-Be 'jnino@oktalab.example.com' + $identity.Login | Should-Be 'JNino@oktalab.example.com' + $identity.DisplayName | Should-BeNull + $identity.GivenName | Should-BeNull + $identity.Enabled | Should-BeTrue + (New-TestIdentity -Provider 'AD' -Login 'josen' -Key 'JOSEN').Key | Should-Be 'josen' + (New-TestIdentity -Provider 'AD' -Login 'josen').Enabled | Should-BeNull + } + } +} + +Describe 'Compare-TestIdentitySnapshot' -Tag 'Unit', 'Private' { + + BeforeEach { + InModuleScope TestEnvironment { + $script:Jose = 'Jos' + [string][char]0x00E9 + ' Ni' + [string][char]0x00F1 + 'o' + $script:JoseDecomposed = 'Jose' + [string][char]0x0301 + ' Ni' + [string][char]0x00F1 + 'o' + $script:Entra = [PSCustomObject]@{ Provider = 'Entra'; Target = 'tenant-1'; Identities = @( + New-TestIdentity -Provider Entra -Login 'ZZ-TEST-jnino@lab.example.com' -Key 'jnino' -DisplayName $script:Jose -Enabled $true + New-TestIdentity -Provider Entra -Login 'ZZ-TEST-danj@lab.example.com' -Key 'danj' -DisplayName 'Dan Jump' -Enabled $true + New-TestIdentity -Provider Entra -Login 'ZZ-TEST-mbell@lab.example.com' -Key 'mbell' -DisplayName 'Marcus Bell' -Enabled $false + New-TestIdentity -Provider Entra -Login 'ZZ-TEST-awhitfield@lab.example.com' -Key 'awhitfield' -DisplayName 'Ada Whitfield' -Enabled $true + ) + } + # The domain logs the same people in differently, and one of them came back decomposed. + $script:AD = [PSCustomObject]@{ Provider = 'AD'; Target = 'contoso.com'; Identities = @( + New-TestIdentity -Provider AD -Login 'josen' -DisplayName $script:JoseDecomposed -GivenName 'Jose' -Surname 'Nino' -Enabled $true + New-TestIdentity -Provider AD -Login 'danj' -DisplayName 'Dan Jump' -Enabled $true + New-TestIdentity -Provider AD -Login 'marcusb' -DisplayName 'Marcus Bell' -Enabled $true + New-TestIdentity -Provider AD -Login 'zoem' -DisplayName ('Zo' + [string][char]0xEB + ' M' + [string][char]0xFC + 'ller') -Enabled $true + ) + } + } + } + + It 'matches by key, then by display name, and reports the rest as only on one side without failing for it' { + InModuleScope TestEnvironment { + $result = Compare-TestIdentitySnapshot -Left $script:Entra -Right $script:AD + + $result.Left.Provider | Should-Be 'Entra' + $result.Left.Count | Should-Be 4 + $result.Right.Count | Should-Be 4 + $result.Matched | Should-Be 3 + $result.MatchedByKey | Should-Be 1 + $result.MatchedByName | Should-Be 2 + @($result.OnlyLeft) | Should-BeCollection @('awhitfield (Ada Whitfield)') + @($result.OnlyRight) | Should-BeCollection @(('zoem (Zo' + [string][char]0xEB + ' M' + [string][char]0xFC + 'ller)')) + } + } + + It 'compares matched names by codepoint, so the decomposed twin -eq calls equal is the finding, and it fails the comparison' { + InModuleScope TestEnvironment { + ($script:Jose -eq $script:JoseDecomposed) | Should-BeTrue + $result = Compare-TestIdentitySnapshot -Left $script:Entra -Right $script:AD + + $result.NamesCompared | Should-Be 3 + @($result.NameMismatch).Count | Should-Be 1 + $result.NameMismatch[0] | Should-MatchString "^jnino: Entra has '" + $result.Passed | Should-BeFalse + } + } + + It 'reports an enabled state that differs without a verdict' { + InModuleScope TestEnvironment { + $result = Compare-TestIdentitySnapshot -Left $script:Entra -Right $script:AD + @($result.StateDifference) | Should-BeCollection @('mbell: disabled in Entra, enabled in AD') + + # The same people, agreeing on every name: the state alone does not fail it. + $script:AD.Identities[0].DisplayName = $script:Jose + (Compare-TestIdentitySnapshot -Left $script:Entra -Right $script:AD).Passed | Should-BeTrue + } + } + + It 'compares parts against parts when either side lacks a display name, and never a display name against a composed one' { + InModuleScope TestEnvironment { + # PingOne keeps given and family names and no display name; the Han name puts the + # family name first, so composing one would call it a mismatch against every other provider. + $pingOne = [PSCustomObject]@{ Provider = 'PingOne'; Target = 'env-1'; Identities = @( + New-TestIdentity -Provider PingOne -Login 'zz-test-hkobayashi' -Key 'hkobayashi' -GivenName ([string][char]0x82B1) -Surname ([string][char]0x5C0F + [string][char]0x6797) -Enabled $true + New-TestIdentity -Provider PingOne -Login 'zz-test-jnino' -Key 'jnino' -GivenName ('Jos' + [string][char]0xE9) -Surname ('Ni' + [string][char]0xF1 + 'o') -Enabled $true + ) + } + $entra = [PSCustomObject]@{ Provider = 'Entra'; Target = 't'; Identities = @( + New-TestIdentity -Provider Entra -Login 'x' -Key 'hkobayashi' -DisplayName ([string][char]0x5C0F + [string][char]0x6797 + ' ' + [string][char]0x82B1) -Enabled $true + New-TestIdentity -Provider Entra -Login 'y' -Key 'jnino' -DisplayName $script:Jose -Enabled $true + ) + } + $against = Compare-TestIdentitySnapshot -Left $entra -Right $pingOne + $against.Matched | Should-Be 2 + $against.NamesCompared | Should-Be 0 + $against.Passed | Should-BeTrue + + $freeIpa = [PSCustomObject]@{ Provider = 'FreeIPA'; Target = 'realm'; Identities = @( + New-TestIdentity -Provider FreeIPA -Login 'jnino' -DisplayName $script:Jose -GivenName ('Jos' + [string][char]0xE9) -Surname 'Nino' -Enabled $true + ) + } + # FreeIPA keeps a display name and PingOne does not, so the parts both keep are compared, + # by codepoint, and never FreeIPA's display name against a name composed from PingOne's. + $parts = Compare-TestIdentitySnapshot -Left $freeIpa -Right $pingOne + $parts.NamesCompared | Should-Be 1 + $parts.NameMismatch[0] | Should-MatchString "Ni" # Nino against Niño + $parts.Passed | Should-BeFalse + } + } + + It 'passes two empty sides, with nothing matched and nothing compared' { + InModuleScope TestEnvironment { + $result = Compare-TestIdentitySnapshot -Left ([PSCustomObject]@{ Provider = 'A'; Target = 'a'; Identities = @() }) -Right ([PSCustomObject]@{ Provider = 'B'; Target = 'b'; Identities = @() }) + $result.Matched | Should-Be 0 + $result.Passed | Should-BeTrue + @($result.OnlyLeft) | Should-BeCollection -Count 0 + } + } +} diff --git a/Tests/Unit/Providers/PingOne/Get-PingOneIdentitySnapshot.Tests.ps1 b/Tests/Unit/Providers/PingOne/Get-PingOneIdentitySnapshot.Tests.ps1 new file mode 100644 index 0000000..2afe1ba --- /dev/null +++ b/Tests/Unit/Providers/PingOne/Get-PingOneIdentitySnapshot.Tests.ps1 @@ -0,0 +1,118 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.1.0' } + +<# + The six snapshot readers behind Compare-TestEnvironment, in one suite because what they share + is the point: each reads the users the way teardown finds them and reduces them to the same + identity, with the key stripped of whatever that provider added - the seed prefix on a PingOne + username, the suffix on an Entra UPN, the domain on an Okta login - so the same person carries + the same key everywhere the shared logins are kept. PingOne leaves the display name absent + rather than composing one; FreeIPA reads the lock flag as the inverse of enabled; Entra leaves + guests out; Okta calls only ACTIVE enabled. + + Everything is mocked. No directory is reached. +#> + +BeforeAll { + $moduleRoot = (Split-Path -Path (Split-Path -Path (Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent) -Parent) -Parent) + . (Join-Path $moduleRoot 'Tests\Stubs\Add-ADTestStubPath.ps1') + # Imported once per run, not once per file: a warm forced import costs about 190 ms, and 111 + # files paid it. CI runs the suite shuffled, so state one file leaves behind for another + # fails there rather than hiding in file order. + if (-not (Get-Module TestEnvironment)) { Import-Module (Join-Path $moduleRoot 'TestEnvironment.psd1') } +} + +Describe 'The identity snapshots' -Tag 'Unit', 'Private' { + + It 'PingOne strips the seed prefix from the username and keeps the name as parts only' { + InModuleScope TestEnvironment { + Mock Get-PingOneConnection { @{ EnvironmentId = 'env-1'; Prefix = 'ZZ-TEST-' } } + Mock Get-PingOneSeededObject { @([PSCustomObject]@{ id = 'u1'; username = 'zz-test-jnino'; name = [PSCustomObject]@{ given = 'José'; family = 'Niño' }; enabled = $false }) } + $snapshot = Get-PingOneIdentitySnapshot + $snapshot.Provider | Should-Be 'PingOne' + $snapshot.Target | Should-Be 'env-1' + $snapshot.Identities[0].Key | Should-Be 'jnino' + $snapshot.Identities[0].Login | Should-Be 'zz-test-jnino' + $snapshot.Identities[0].DisplayName | Should-BeNull + $snapshot.Identities[0].GivenName | Should-Be 'José' + $snapshot.Identities[0].Enabled | Should-BeFalse + Should-Invoke Get-PingOneSeededObject -Times 1 -Exactly -ParameterFilter { $Type -eq 'Users' } + } + } + + It 'Entra strips the prefix and suffix from the UPN and leaves guests out' { + InModuleScope TestEnvironment { + Mock Get-EntraConnection { @{ TenantId = 'tenant-1'; UpnSuffix = 'lab.example.com' } } + Mock Get-EntraSeedMarker { [PSCustomObject]@{ Prefix = 'ZZ-TEST-'; UpnSuffix = 'lab.example.com' } } + Mock Get-EntraSeededObject { @( + [PSCustomObject]@{ id = 'u1'; userPrincipalName = 'ZZ-TEST-JNino@lab.example.com'; displayName = 'José Niño'; givenName = 'José'; surname = 'Niño'; accountEnabled = $true; userType = 'Member' } + [PSCustomObject]@{ id = 'g1'; userPrincipalName = 'guest_example.com#EXT#@lab.example.com'; displayName = 'Guest'; accountEnabled = $true; userType = 'Guest' } + ) } + $snapshot = Get-EntraIdentitySnapshot + @($snapshot.Identities).Count | Should-Be 1 + $snapshot.Identities[0].Key | Should-Be 'jnino' + $snapshot.Identities[0].DisplayName | Should-Be 'José Niño' + $snapshot.Identities[0].Surname | Should-Be 'Niño' + $snapshot.Target | Should-Be 'tenant-1' + } + } + + It 'Okta keys by the local part of the login and calls only ACTIVE enabled' { + InModuleScope TestEnvironment { + Mock Get-OktaConnection { @{ OrgUrl = 'https://trial-1.okta.com'; Prefix = 'OKTALAB'; EmailDomain = 'oktalab.example.com' } } + Mock Get-OktaSeededUser { @( + [PSCustomObject]@{ id = 'u1'; status = 'ACTIVE'; profile = [PSCustomObject]@{ login = 'jnino@oktalab.example.com'; displayName = 'José Niño'; firstName = 'José'; lastName = 'Niño' } } + [PSCustomObject]@{ id = 'u2'; status = 'SUSPENDED'; profile = [PSCustomObject]@{ login = 'mbell@oktalab.example.com'; displayName = 'Marcus Bell'; firstName = 'Marcus'; lastName = 'Bell' } } + ) } + $snapshot = Get-OktaIdentitySnapshot + @($snapshot.Identities | ForEach-Object { $_.Key }) | Should-BeCollection @('jnino', 'mbell') + @($snapshot.Identities | ForEach-Object { $_.Enabled }) | Should-BeCollection @($true, $false) + $snapshot.Identities[0].Surname | Should-Be 'Niño' + } + } + + It 'Authentik keys by the username and keeps the one name field as the display name' { + InModuleScope TestEnvironment { + Mock Get-AuthentikConnection { @{ BaseUrl = 'https://auth.example.com'; Prefix = 'ZZ-TEST-' } } + Mock Get-AuthentikSeededObject { @([PSCustomObject]@{ pk = 1; username = 'jnino'; name = 'José Niño'; is_active = $true }) } + $snapshot = Get-AuthentikIdentitySnapshot + $snapshot.Identities[0].Key | Should-Be 'jnino' + $snapshot.Identities[0].DisplayName | Should-Be 'José Niño' + $snapshot.Identities[0].GivenName | Should-BeNull + $snapshot.Target | Should-Be 'https://auth.example.com' + } + } + + It 'FreeIPA reads the detail, unwraps the lists, and reads the lock flag as the inverse of enabled' { + InModuleScope TestEnvironment { + Mock Get-FreeIPAConnection { @{ BaseUrl = 'https://ipa.example.com'; Prefix = 'ZZ-TEST-' } } + Mock Get-FreeIPASeededObject { @( + [PSCustomObject]@{ uid = @('jnino'); displayname = @('José Niño'); givenname = @('José'); sn = @('Niño'); nsaccountlock = $false } + [PSCustomObject]@{ uid = @('talvarez'); displayname = @('Tomás Álvarez'); givenname = @('Tomás'); sn = @('Álvarez'); nsaccountlock = $true } + ) } + $snapshot = Get-FreeIPAIdentitySnapshot + @($snapshot.Identities | ForEach-Object { $_.Key }) | Should-BeCollection @('jnino', 'talvarez') + @($snapshot.Identities | ForEach-Object { $_.Enabled }) | Should-BeCollection @($true, $false) + $snapshot.Identities[0].DisplayName | Should-Be 'José Niño' + Should-Invoke Get-FreeIPASeededObject -Times 1 -Exactly -ParameterFilter { $Type -eq 'Users' -and $Detail } + } + } + + It 'AD keys by the SAM account name, reads only the tagged users under the seed OU, and reads none when the OU is gone' { + InModuleScope TestEnvironment { + Mock Get-ADTestDomain { @{ DNSName = 'contoso.com'; DomainDN = 'DC=contoso,DC=com' } } + Mock Get-ADTestSeedMarker { [PSCustomObject]@{ Prefix = 'ZZ-TEST-'; Tag = 'ZZ-TEST-seed' } } + Mock Get-ADOrganizationalUnit { @([PSCustomObject]@{ Name = 'ZZ-TEST-TestData' }) } + Mock Get-ADUser { @( + [PSCustomObject]@{ SamAccountName = 'JoseN'; DisplayName = 'José Niño'; GivenName = 'José'; Surname = 'Niño'; Enabled = $true; adminDescription = 'ZZ-TEST-seed' } + [PSCustomObject]@{ SamAccountName = 'intruder'; DisplayName = 'Not Ours'; Enabled = $true; adminDescription = $null } + ) } + $snapshot = Get-ADIdentitySnapshot -WarningAction SilentlyContinue + @($snapshot.Identities | ForEach-Object { $_.Key }) | Should-BeCollection @('josen') + $snapshot.Target | Should-Be 'contoso.com' + Should-Invoke Get-ADUser -Times 1 -Exactly -ParameterFilter { $SearchBase -eq 'OU=Users,OU=ZZ-TEST-TestData,DC=contoso,DC=com' } + + Mock Get-ADOrganizationalUnit { @() } + @((Get-ADIdentitySnapshot).Identities) | Should-BeCollection -Count 0 + } + } +} diff --git a/Tests/Unit/Public/Compare-TestEnvironment.Tests.ps1 b/Tests/Unit/Public/Compare-TestEnvironment.Tests.ps1 new file mode 100644 index 0000000..c3574c2 --- /dev/null +++ b/Tests/Unit/Public/Compare-TestEnvironment.Tests.ps1 @@ -0,0 +1,97 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.1.0' } + +<# + The one exported command that reads two providers at once. It names its providers rather than + reading the active one, because two are involved; it refuses a provider that is not loaded and + the same provider twice; it reaches each provider's own snapshot reader and hands both to the + comparison; and it says plainly, when a reader fails, that both providers must be connected in + this session. Every provider folder on disk is held to having a snapshot reader, so a seventh + provider is comparable the day its folder appears. +#> + +BeforeAll { + $script:ModuleRoot = (Split-Path -Path (Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent) -Parent) + . (Join-Path $script:ModuleRoot 'Tests\Stubs\Add-ADTestStubPath.ps1') + # Imported once per run, not once per file: a warm forced import costs about 190 ms, and 111 + # files paid it. CI runs the suite shuffled, so state one file leaves behind for another + # fails there rather than hiding in file order. + if (-not (Get-Module TestEnvironment)) { Import-Module (Join-Path $script:ModuleRoot 'TestEnvironment.psd1') } +} + +Describe 'Compare-TestEnvironment' -Tag 'Unit', 'Public' { + + BeforeDiscovery { + $script:Provider = @(Get-ChildItem -Path (Join-Path (Split-Path -Path (Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent) -Parent) 'Providers') -Directory | + ForEach-Object { @{ Name = $_.Name } }) + } + + BeforeEach { + InModuleScope TestEnvironment { + Mock Write-TestMessage { } + Mock Get-EntraIdentitySnapshot { + [PSCustomObject]@{ Provider = 'Entra'; Target = 'tenant-1'; Identities = @( + New-TestIdentity -Provider Entra -Login 'ZZ-TEST-jnino@lab.example.com' -Key 'jnino' -DisplayName 'José Niño' -Enabled $true + New-TestIdentity -Provider Entra -Login 'ZZ-TEST-awhitfield@lab.example.com' -Key 'awhitfield' -DisplayName 'Ada Whitfield' -Enabled $true + ) + } + } + Mock Get-PingOneIdentitySnapshot { + [PSCustomObject]@{ Provider = 'PingOne'; Target = 'env-1'; Identities = @( + New-TestIdentity -Provider PingOne -Login 'zz-test-jnino' -Key 'jnino' -GivenName 'José' -Surname 'Niño' -Enabled $false + ) + } + } + } + } + + It 'reads both providers through their own snapshot readers and returns the comparison' { + InModuleScope TestEnvironment { + $result = Compare-TestEnvironment -Provider Entra, PingOne -Quiet + + $result.PSObject.TypeNames[0] | Should-Be 'TestEnvironmentComparison' + $result.Left.Provider | Should-Be 'Entra' + $result.Right.Provider | Should-Be 'PingOne' + $result.Matched | Should-Be 1 + @($result.OnlyLeft) | Should-BeCollection @('awhitfield (Ada Whitfield)') + $result.Passed | Should-BeTrue + Should-Invoke Get-EntraIdentitySnapshot -Times 1 -Exactly + Should-Invoke Get-PingOneIdentitySnapshot -Times 1 -Exactly + Should-NotInvoke Write-TestMessage + } + } + + It 'prints the counts, the matches, each side''s own people, the states and the verdict' { + InModuleScope TestEnvironment { + $null = Compare-TestEnvironment -Provider Entra, PingOne + Should-Invoke Write-TestMessage -ParameterFilter { $Type -eq 'Header' -and $Message -like '*Entra and PingOne*' } + Should-Invoke Write-TestMessage -ParameterFilter { $Message -eq 'Entra: 2 people in tenant-1' } + Should-Invoke Write-TestMessage -ParameterFilter { $Message -eq 'Matched 1: 1 by login key, 0 by display name' } + Should-Invoke Write-TestMessage -ParameterFilter { $Message -eq 'Only in Entra: 1 (awhitfield (Ada Whitfield))' } + Should-Invoke Write-TestMessage -ParameterFilter { $Message -eq 'Only in PingOne: none' } + Should-Invoke Write-TestMessage -ParameterFilter { $Message -like 'Enabled differs for 1*' -and $Type -eq 'Info' } + Should-Invoke Write-TestMessage -ParameterFilter { $Type -eq 'Success' -and $Message -like 'Names agree*' } + } + } + + It 'refuses a provider it does not know, and the same provider twice' { + InModuleScope TestEnvironment { + { Compare-TestEnvironment -Provider Entra, Nowhere -Quiet } | Should-Throw -ExceptionMessage "*No provider called 'Nowhere'*" + { Compare-TestEnvironment -Provider Entra, Entra -Quiet } | Should-Throw -ExceptionMessage '*two different providers*' + { Compare-TestEnvironment -Provider Entra -Quiet } | Should-Throw + } + } + + It 'says that both providers must be connected when one cannot be read' { + InModuleScope TestEnvironment { + Mock Get-PingOneIdentitySnapshot { throw 'Not connected to PingOne. Run Connect-PingOneEnvironment first.' } + { Compare-TestEnvironment -Provider Entra, PingOne -Quiet } | Should-Throw -ExceptionMessage '*Both providers must be connected in this session*Not connected to PingOne*' + } + } + + It 'the provider implements Get-IdentitySnapshot' -ForEach $script:Provider { + InModuleScope TestEnvironment -Parameters @{ Provider = $Name } { + param($Provider) + Get-Command -Name ('Get-{0}IdentitySnapshot' -f $Provider) -ErrorAction SilentlyContinue | Should-NotBeNull + } + } +} diff --git a/docs/TestEnvironment/Compare-TestEnvironment.md b/docs/TestEnvironment/Compare-TestEnvironment.md new file mode 100644 index 0000000..c308869 --- /dev/null +++ b/docs/TestEnvironment/Compare-TestEnvironment.md @@ -0,0 +1,158 @@ +--- +document type: cmdlet +external help file: TestEnvironment-Help.xml +HelpUri: https://github.com/fadwen/TestEnvironment/blob/main/docs/TestEnvironment/Compare-TestEnvironment.md +Locale: en-US +Module Name: TestEnvironment +ms.date: 09 14 2026 +PlatyPS schema version: 2024-05-01 +title: Compare-TestEnvironment +--- + +# Compare-TestEnvironment + +## SYNOPSIS + +Compares the people two connected providers hold, the way a hybrid identity match would + +## SYNTAX + +### __AllParameterSets + +``` +Compare-TestEnvironment [-Provider] [-Quiet] +``` + +## DESCRIPTION + +The seed puts the same people into every directory it knows - the nine written in other writing +systems, the nine core people, the bulk of the Active Directory data mapped into Entra, Authentik, +FreeIPA and PingOne - so that whatever matches identities across two directories can be tested +against them. This reads the seeded people from two providers connected in this session and reports +how they line up. + +People are matched by login key first - the login with the provider's additions stripped, so jnino +is jnino in every provider that keeps the shared logins - and then by display name among what is +left, because the Active Directory data logs its people in as first name and initial and agrees +with the others only on the names. The name match folds case and Unicode normalisation, so a name +one directory stored decomposed still finds its person, and the codepoint difference is then +reported as the finding it is. What matches in neither way is reported as only on one side; that is +not a fault, because the providers hold deliberately different populations, and the report says so +rather than judging it. + +For every matched pair the names are compared by codepoint, never with -eq, which calls a +decomposed and a precomposed name equal: display names where both providers keep one, otherwise the +given name and surname where both keep those - so a tenant is compared with a PingOne environment, +which stores no display name, on the parts - and never a stored display name against one composed +from parts, which would call every family-name-first person a mismatch. A person whose name differs +between two directories is the finding a hybrid match would trip over, and it is the one thing this +command judges. Whether the account is enabled is compared and reported without a verdict, because +the seed hangs different states on the same person in different providers on purpose. + +Both providers must be connected in this session. Connect-TestEnvironment keeps one active for the +other commands, but every provider keeps its own connection, so connecting to a second does not drop +the first. + +## EXAMPLES + +### Example 1: Compares a tenant with a PingOne environment + +```powershell +Connect-TestEnvironment -Provider Entra -TenantId $tenant -UseStoredCredential +Connect-TestEnvironment -Provider PingOne -EnvironmentId $environment -ClientId $client -UseStoredCredential +Compare-TestEnvironment -Provider Entra, PingOne +``` + +Output: The counts on each side, how many matched by key and by name, who is only on one side, whose +enabled state differs, and whether every matched name agrees. + +Use case: Proving that the two directories a hybrid identity tool is about to match hold the same +people under the same names. + +### Example 2: Lists the people one side holds and the other does not + +```powershell +(Compare-TestEnvironment -Provider AD, Entra -Quiet).OnlyRight +``` + +Output: One line per person, as key and display name. + +Use case: The Entra data carries core people the Active Directory data does not; this names them. + +## PARAMETERS + +### -Provider + +The two providers to compare, as Get-TestEnvironmentProvider names them. + +```yaml +Type: System.String[] +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: 0 + IsRequired: true + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -Quiet + +Return the result without writing to the console. + +```yaml +Type: System.Management.Automation.SwitchParameter +DefaultValue: False +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### CommonParameters + +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, +-InformationAction, -InformationVariable, -OutBuffer, -OutVariable, -PipelineVariable, +-ProgressAction, -Verbose, -WarningAction, and -WarningVariable. For more information, see +[about_CommonParameters](https://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +### None + +This command does not accept pipeline input. + +## OUTPUTS + +### TestEnvironmentComparison + +Left and Right, each with Provider, Target and Count; Matched, MatchedByKey, MatchedByName and +NamesCompared; OnlyLeft, OnlyRight, NameMismatch and StateDifference as lists; and Passed, which is +$true when every matched pair's names agree. + +## NOTES + +Author: Jeffrey Stuhr +Blog: https://www.techbyjeff.net +LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + +## RELATED LINKS + +- [Connect-TestEnvironment]() +- [Test-TestEnvironment]() +- [Get-TestEnvironmentReport]() +- [about_TestEnvironment]() diff --git a/docs/TestEnvironment/TestEnvironment.md b/docs/TestEnvironment/TestEnvironment.md index 055b849..1ecfe18 100644 --- a/docs/TestEnvironment/TestEnvironment.md +++ b/docs/TestEnvironment/TestEnvironment.md @@ -18,6 +18,10 @@ Seeds and tears down realistic identity test environments across several provide ## TestEnvironment Cmdlets +### [Compare-TestEnvironment](Compare-TestEnvironment.md) + +Compares the people two connected providers hold, the way a hybrid identity match would + ### [Connect-TestEnvironment](Connect-TestEnvironment.md) Connects to an identity provider, and fixes which provider the session works against diff --git a/en-US/TestEnvironment-Help.xml b/en-US/TestEnvironment-Help.xml index 2c386d4..d63c122 100644 --- a/en-US/TestEnvironment-Help.xml +++ b/en-US/TestEnvironment-Help.xml @@ -1,5 +1,170 @@ + + + Compare-TestEnvironment + + Compares the people two connected providers hold, the way a hybrid identity match would + + Compare + TestEnvironment + + + The seed puts the same people into every directory it knows - the nine written in other writing +systems, the nine core people, the bulk of the Active Directory data mapped into Entra, Authentik, +FreeIPA and PingOne - so that whatever matches identities across two directories can be tested +against them. This reads the seeded people from two providers connected in this session and reports +how they line up. + +People are matched by login key first - the login with the provider's additions stripped, so jnino +is jnino in every provider that keeps the shared logins - and then by display name among what is +left, because the Active Directory data logs its people in as first name and initial and agrees +with the others only on the names. The name match folds case and Unicode normalisation, so a name +one directory stored decomposed still finds its person, and the codepoint difference is then +reported as the finding it is. What matches in neither way is reported as only on one side; that is +not a fault, because the providers hold deliberately different populations, and the report says so +rather than judging it. + +For every matched pair the names are compared by codepoint, never with -eq, which calls a +decomposed and a precomposed name equal: display names where both providers keep one, otherwise the +given name and surname where both keep those - so a tenant is compared with a PingOne environment, +which stores no display name, on the parts - and never a stored display name against one composed +from parts, which would call every family-name-first person a mismatch. A person whose name differs +between two directories is the finding a hybrid match would trip over, and it is the one thing this +command judges. Whether the account is enabled is compared and reported without a verdict, because +the seed hangs different states on the same person in different providers on purpose. + +Both providers must be connected in this session. Connect-TestEnvironment keeps one active for the +other commands, but every provider keeps its own connection, so connecting to a second does not drop +the first. + + + + Compare-TestEnvironment + + Provider + + string[] + + System.String[] + + + + Quiet + + + System.Management.Automation.SwitchParameter + + + + + + + Provider + + The two providers to compare, as Get-TestEnvironmentProvider names them. + + System.String[] + + System.String[] + + + + Quiet + + Return the result without writing to the console. + + + System.Management.Automation.SwitchParameter + + + + + + + None + + + This command does not accept pipeline input. + + + + + + + TestEnvironmentComparison + + + Left and Right, each with Provider, Target and Count; Matched, MatchedByKey, MatchedByName and +NamesCompared; OnlyLeft, OnlyRight, NameMismatch and StateDifference as lists; and Passed, which is +$true when every matched pair's names agree. + + + + + + Author: Jeffrey Stuhr +Blog: https://www.techbyjeff.net +LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + + + + + --------- Example 1: Compares a tenant with a PingOne environment --------- + + ```powershell +Connect-TestEnvironment -Provider Entra -TenantId $tenant -UseStoredCredential +Connect-TestEnvironment -Provider PingOne -EnvironmentId $environment -ClientId $client -UseStoredCredential +Compare-TestEnvironment -Provider Entra, PingOne +``` + + Output: The counts on each side, how many matched by key and by name, who is only on one side, whose +enabled state differs, and whether every matched name agrees. + + Use case: Proving that the two directories a hybrid identity tool is about to match hold the same +people under the same names. + + + + + + --------- Example 2: Lists the people one side holds and the other does not --------- + + ```powershell +(Compare-TestEnvironment -Provider AD, Entra -Quiet).OnlyRight +``` + + Output: One line per person, as key and display name. + + Use case: The Entra data carries core people the Active Directory data does not; this names them. + + + + + + + + Online Version + https://github.com/fadwen/TestEnvironment/blob/main/docs/TestEnvironment/Compare-TestEnvironment.md + + + Connect-TestEnvironment + + + + Test-TestEnvironment + + + + Get-TestEnvironmentReport + + + + about_TestEnvironment + + + + Connect-TestEnvironment diff --git a/en-US/about_TestEnvironment.help.txt b/en-US/about_TestEnvironment.help.txt index 72f0a6a..12bcbfe 100644 --- a/en-US/about_TestEnvironment.help.txt +++ b/en-US/about_TestEnvironment.help.txt @@ -52,7 +52,10 @@ THE PROVIDER IS NAMED ONCE Get-TestEnvironmentProvider lists the providers that were discovered at import and which one is active. Only one provider is active at a time; - Disconnect-TestEnvironment releases it. + Disconnect-TestEnvironment releases it. Every provider keeps its own + connection, though, so connecting to a second does not drop the first, and + Compare-TestEnvironment -Provider A, B reads the seeded people from both + and reports how they line up. WHAT EACH PROVIDER CREATES The provider-specific New- commands are exported so a single object type @@ -202,6 +205,7 @@ SEE ALSO New-TestEnvironment Get-TestEnvironmentReport Test-TestEnvironment + Compare-TestEnvironment Remove-TestEnvironment Get-TestEnvironmentProvider https://github.com/fadwen/TestEnvironment