From 5bd820f8e617f900eb49d483002155761c2681ec Mon Sep 17 00:00:00 2001 From: SingularVector Date: Sat, 24 Oct 2026 14:47:07 +0200 Subject: [PATCH] Add policy-aligned UPN generation and lookup to IT-ToolBox 3.3.0 --- CHANGELOG.md | 8 ++ IT-ToolBox.psd1 | 6 +- IT-ToolBox.psm1 | 2 + Public/Get-UniqueUPN.ps1 | 151 +++++++++++++++++++++ Public/Test-UpnExist.ps1 | 120 +++++++++++++++++ README.md | 45 +++++-- Tests/IntegrationBoundary.Tests.ps1 | 2 +- Tests/Module.Tests.ps1 | 4 +- Tests/Packaging.Tests.ps1 | 10 +- Tests/Upn.Tests.ps1 | 195 ++++++++++++++++++++++++++++ docs/Packaging.md | 12 +- docs/UPN.md | 81 ++++++++++++ 12 files changed, 610 insertions(+), 26 deletions(-) create mode 100644 Public/Get-UniqueUPN.ps1 create mode 100644 Public/Test-UpnExist.ps1 create mode 100644 Tests/Upn.Tests.ps1 create mode 100644 docs/UPN.md diff --git a/CHANGELOG.md b/CHANGELOG.md index fd19cb8..340e6e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # IT-ToolBox - Change History +## 3.3.0 - 2026-10-24 + +- Port Get-UniqueUPN and Test-UPNExist, bringing the supported API to 33 commands while keeping AD dependencies optional at import. +- Validate generated candidates against Test-IsValidUpn before directory lookup; reject unsupported names and separators without silent transliteration. +- Preserve explicit forest/Global Catalog and domain coverage, escaped LDAP equality filters, bounded collision attempts and propagated query failures. +- Handle an absent optional ADUser middle-name property under StrictMode and validate explicit server ports within 1–65535. +- Add offline query-construction and policy tests; document live AD validation boundaries and the preflight-only uniqueness guarantee. + ## 3.2.0 - 2026-10-24 - Port Test-IsRegistryKey from PowerShell-Functions, bringing the supported API to 31 commands. diff --git a/IT-ToolBox.psd1 b/IT-ToolBox.psd1 index eca9087..d84b08f 100644 --- a/IT-ToolBox.psd1 +++ b/IT-ToolBox.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'IT-ToolBox.psm1' - ModuleVersion = '3.2.0' + ModuleVersion = '3.3.0' GUID = '02c0c748-00ba-4e8f-b8ce-6aa7e4cefe08' Author = 'Daniele Catanesi - SingularVector' CompanyName = 'Community' @@ -30,6 +30,8 @@ 'Test-RegistryValue' 'Test-IsValidDn' 'Test-IsValidUpn' + 'Test-UPNExist' + 'Get-UniqueUPN' 'Get-ReportChain' 'New-StringEncryption' 'New-StringDecryption' @@ -46,7 +48,7 @@ AliasesToExport = @() PrivateData = @{ PSData = @{ - ReleaseNotes = '3.2.0: Add Windows-only Test-IsRegistryKey; share registry path validation and key access with Test-RegistryValue. Exports 31 commands with no new dependencies. See CHANGELOG.md.' + ReleaseNotes = '3.3.0: Add Get-UniqueUPN and Test-UPNExist with explicit AD search scope and consistent UPN naming policy. Exports 33 commands; ActiveDirectory remains optional at import. See CHANGELOG.md.' Tags = @('Infrastructure', 'Automation', 'Logging') LicenseUri = 'https://github.com/SingularVector/IT-ToolBox/blob/master/LICENSE' ProjectUri = 'https://github.com/SingularVector/IT-ToolBox' diff --git a/IT-ToolBox.psm1 b/IT-ToolBox.psm1 index 6824170..6482ff6 100644 --- a/IT-ToolBox.psm1 +++ b/IT-ToolBox.psm1 @@ -30,6 +30,8 @@ Export-ModuleMember -Function @( 'Test-RegistryValue' 'Test-IsValidDn' 'Test-IsValidUpn' + 'Test-UPNExist' + 'Get-UniqueUPN' 'Get-ReportChain' 'New-StringEncryption' 'New-StringDecryption' diff --git a/Public/Get-UniqueUPN.ps1 b/Public/Get-UniqueUPN.ps1 new file mode 100644 index 0000000..ec5e333 --- /dev/null +++ b/Public/Get-UniqueUPN.ps1 @@ -0,0 +1,151 @@ +function Get-UniqueUPN +{ + <# + .SYNOPSIS + Finds an available user principal name in the selected directory scope. + .DESCRIPTION + Accepts explicit name strings or ADUser, DirectoryEntry and SearchResult + inputs. First and last name formatting are independent. Collisions append + an integer to the complete local part; the search has a bounded attempt count. + Availability is a preflight check, not a reservation or atomic uniqueness guarantee. + Generated candidates must satisfy Test-IsValidUpn's ASCII username and DNS/IDN + suffix policy. Normalize unsupported names explicitly; no transliteration is applied. + Requires ActiveDirectory lookup commands when invoked; script loading is portable. + .PARAMETER MaxAttempts + Maximum candidates to check, including the initial unsuffixed name. Defaults to 1000. + .PARAMETER AdServer + Directory hostname, optionally with port. See Test-UPNExist for scope behaviour. + #> + [CmdletBinding(DefaultParameterSetName = 'Strings')] + [OutputType([string])] + param ( + [Parameter(ParameterSetName = 'ADObject', Mandatory)] + [ValidateNotNull()] + [object]$ADObject, + + [Parameter(ParameterSetName = 'Strings', Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$FirstName, + + [Parameter(ParameterSetName = 'Strings', Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$LastName, + + [Parameter(ParameterSetName = 'Strings')] + [string]$MiddleName, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$UPNSuffix, + + [ValidateSet('FullName', 'FirstLetter')] + [string]$FirstNameFormat = 'FullName', + + [ValidateSet('FullName', 'FirstLetter')] + [string]$LastNameFormat = 'FullName', + + [switch]$IncludeMiddleName, + + [Alias('Server')] + [ValidatePattern('^[A-Za-z0-9][A-Za-z0-9._-]*(?::[0-9]{1,5})?$')] + [ValidateScript({ + if ($_ -match ':([0-9]+)$') { [int]$Matches[1] -ge 1 -and [int]$Matches[1] -le 65535 } + else { $true } + })] + [string]$ADServer, + + [ValidatePattern('^[^\s@\p{Cc}]*$')] + [AllowEmptyString()] + [string]$Separator = '.', + + [ValidateSet('Forest', 'Domain')] + [string]$SearchScope = 'Forest', + + [ValidateRange(1, 100000)] + [int]$MaxAttempts = 1000 + ) + + $givenName = $FirstName + $surname = $LastName + $middleNameValue = $MiddleName + + if ($PSCmdlet.ParameterSetName -eq 'ADObject') + { + if ($ADObject.PSObject.TypeNames -contains 'Microsoft.ActiveDirectory.Management.ADUser') + { + $givenName = [string]$ADObject.GivenName + $surname = [string]$ADObject.Surname + $middleProperty = $ADObject.PSObject.Properties['MiddleName'] + $middleNameValue = if ($null -ne $middleProperty) { [string]$middleProperty.Value } else { '' } + } + elseif ($ADObject.PSObject.TypeNames -contains 'System.DirectoryServices.DirectoryEntry' -or + $ADObject.PSObject.TypeNames -contains 'System.DirectoryServices.SearchResult') + { + $givenName = [string](@($ADObject.Properties['givenName'])[0]) + $surname = [string](@($ADObject.Properties['sn'])[0]) + $middleNameValue = [string](@($ADObject.Properties['middleName'])[0]) + } + else + { + throw "Unsupported AD object type: $($ADObject.GetType().FullName)" + } + } + + foreach ($name in @($givenName, $surname)) + { + if ([string]::IsNullOrWhiteSpace($name) -or $name.Trim() -match '[\s@\p{Cc}]') + { + throw 'First and last names must contain nonblank components without embedded whitespace, controls or @. Normalize compound names before calling.' + } + } + if (-not (Test-ITToolBoxDnsName -Name $UPNSuffix)) + { + throw 'UPNSuffix must be a DNS/IDN name without whitespace or a trailing root dot.' + } + + $first = $givenName.Trim() + $last = $surname.Trim() + if ($FirstNameFormat -eq 'FirstLetter') + { + $first = [System.Globalization.StringInfo]::GetNextTextElement($first) + } + if ($LastNameFormat -eq 'FirstLetter') + { + $last = [System.Globalization.StringInfo]::GetNextTextElement($last) + } + $parts = [System.Collections.Generic.List[string]]::new() + $parts.Add($first) + if ($IncludeMiddleName -and -not [string]::IsNullOrWhiteSpace($middleNameValue)) + { + $middle = $middleNameValue.Trim() + if ($middle -match '[\s@\p{Cc}]') + { + throw 'Middle name must not contain embedded whitespace, controls or @.' + } + $parts.Add($middle) + } + $parts.Add($last) + $localPart = ($parts -join $Separator).ToLowerInvariant() + $suffix = $UPNSuffix.ToLowerInvariant() + $initialCandidate = '{0}@{1}' -f $localPart, $suffix + if (-not (Test-IsValidUpn -UserUpn $initialCandidate)) + { + throw 'Generated UPN does not match the supported syntax policy (ASCII username and DNS/IDN suffix). Normalize the name or separator explicitly before calling.' + } + $lookup = @{ SearchScope = $SearchScope; ErrorAction = 'Stop' } + if ($PSBoundParameters.ContainsKey('ADServer')) + { + $lookup.AdServer = $ADServer + } + + for ($attempt = 0; $attempt -lt $MaxAttempts; $attempt++) + { + $counter = if ($attempt -eq 0) { '' } else { [string]$attempt } + $candidate = '{0}{1}@{2}' -f $localPart, $counter, $suffix + if (-not (Test-UPNExist -UPN $candidate @lookup)) + { + return $candidate + } + } + throw "No available UPN found after $MaxAttempts candidates." +} diff --git a/Public/Test-UpnExist.ps1 b/Public/Test-UpnExist.ps1 new file mode 100644 index 0000000..dd6c6f3 --- /dev/null +++ b/Public/Test-UpnExist.ps1 @@ -0,0 +1,120 @@ +function Test-UPNExist +{ + <# + .SYNOPSIS + Tests whether a user principal name exists in a forest or domain. + .DESCRIPTION + Requires the ActiveDirectory module commands at invocation, not script load. + Forest searches use a Global Catalog and an empty search base to search all + partitions. Domain searches use the server's default naming context. + Query and discovery errors terminate; they are not treated as an unused UPN. + .PARAMETER AdServer + Server hostname, optionally with a port. Server is retained as an alias. + Forest scope accepts GC ports 3268 or 3269 and defaults to 3268. + The supplied host must actually provide a Global Catalog service. + .PARAMETER SearchScope + Forest (default) or Domain. This selects the directory coverage, not an LDAP + Base/OneLevel/Subtree option; the LDAP search itself uses Subtree. + #> + [CmdletBinding()] + [OutputType([bool])] + param ( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$UPN, + + [Alias('Server')] + [ValidatePattern('^[A-Za-z0-9][A-Za-z0-9._-]*(?::[0-9]{1,5})?$')] + [ValidateScript({ + if ($_ -match ':([0-9]+)$') { [int]$Matches[1] -ge 1 -and [int]$Matches[1] -le 65535 } + else { $true } + })] + [string]$AdServer, + + [ValidateSet('Forest', 'Domain')] + [string]$SearchScope = 'Forest' + ) + + $ErrorActionPreference = 'Stop' + if ([string]::IsNullOrWhiteSpace($UPN)) + { + throw [ArgumentException]::new('UPN must not be blank.') + } + + if (-not (Get-Command Get-ADUser -ErrorAction SilentlyContinue)) + { + throw [InvalidOperationException]::new('Test-UPNExist requires Get-ADUser from the ActiveDirectory module and a reachable directory.') + } + + $query = @{ + LDAPFilter = '(&(objectCategory=person)(objectClass=user)(userPrincipalName={0}))' -f (ConvertTo-ITToolBoxLdapFilterValue -Value $UPN) + SearchScope = 'Subtree' + ResultSetSize = 1 + ErrorAction = 'Stop' + } + + if ($SearchScope -eq 'Forest') + { + $server = $AdServer + if (-not $PSBoundParameters.ContainsKey('AdServer')) + { + if (-not (Get-Command Get-ADDomainController -ErrorAction SilentlyContinue)) + { + throw 'Forest discovery requires Get-ADDomainController from the ActiveDirectory module.' + } + $controller = Get-ADDomainController -Discover -Service GlobalCatalog -ErrorAction Stop + $server = [string]$controller.HostName + if ([string]::IsNullOrWhiteSpace($server)) + { + throw 'Global Catalog discovery returned no hostname.' + } + } + + if ($server -match ':([0-9]+)$') + { + if ($Matches[1] -notin @('3268', '3269')) + { + throw 'Forest scope requires a Global Catalog port (3268 or 3269).' + } + } + else + { + $server += ':3268' + } + + $query.Server = $server + $query.SearchBase = '' + } + else + { + if (-not (Get-Command Get-ADRootDSE -ErrorAction SilentlyContinue)) + { + throw 'Domain scope requires Get-ADRootDSE from the ActiveDirectory module.' + } + $rootQuery = @{ ErrorAction = 'Stop' } + if ($PSBoundParameters.ContainsKey('AdServer')) + { + $rootQuery.Server = $AdServer + $query.Server = $AdServer + } + $root = Get-ADRootDSE @rootQuery + $base = [string]$root.DefaultNamingContext + if ([string]::IsNullOrWhiteSpace($base)) + { + throw 'Directory discovery returned no default naming context.' + } + # Pin the user search to the same controller used to discover the domain. + if (-not $query.ContainsKey('Server')) + { + if ([string]::IsNullOrWhiteSpace([string]$root.DnsHostName)) + { + throw 'Directory discovery returned no controller hostname.' + } + $query.Server = [string]$root.DnsHostName + } + $query.SearchBase = $base + } + + $found = @(Get-ADUser @query) + return ($found.Count -gt 0) +} diff --git a/README.md b/README.md index d55a899..d77008f 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,10 @@ [![PowerShell 7.4+](https://img.shields.io/badge/PowerShell-7.4%2B-5391FE?logo=powershell)](https://github.com/PowerShell/PowerShell) [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](./LICENSE) -[![Version: 3.2.0](https://img.shields.io/badge/version-3.2.0-blue.svg)](./CHANGELOG.md) +[![Version: 3.3.0](https://img.shields.io/badge/version-3.3.0-blue.svg)](./CHANGELOG.md) [![Testing wanted](https://img.shields.io/badge/testing-wanted-yellow.svg)](https://github.com/SingularVector/IT-ToolBox/issues/new/choose) -> **Testing wanted — help validate IT-ToolBox 3.2.0 in real environments.** +> **Testing wanted — help validate IT-ToolBox 3.3.0 in real environments.** > Feedback is especially useful for Windows AD report chains, remote CIM uptime, > API authentication and platform clipboard behavior. > [Report a bug](https://github.com/SingularVector/IT-ToolBox/issues/new/choose) @@ -24,7 +24,7 @@ and remote CIM commands have the platform requirements listed below. | --- | --- | | Logging and operations | `New-LogEntry`, `New-Timer`, `Get-ElapsedTime`, `Stop-Timer` | | Validation | `Test-IsEmail`, `Test-IsUrl`, `Test-IsValidDn`, `Test-IsValidUpn` | -| Identity and AD helpers | `Get-ReportChain`, `Convert-LogonTimestamp`, `Test-IsValidUpn` | +| Identity and AD helpers | `Get-UniqueUPN`, `Test-UPNExist`, `Get-ReportChain`, `Convert-LogonTimestamp` | | String handling and passwords | `New-StringEncryption`, `New-StringDecryption`, `New-PhoneticPassword`, `Get-StringHashCode` | | OS and filesystem | `Get-OsUpTime`, `Remove-SpecialCharacters`, `Test-RegistryValue` | @@ -38,17 +38,17 @@ and remote CIM commands have the platform requirements listed below. ## Release status -**IT-ToolBox 3.2.0** contains the 31-command API documented below, including registry -key existence checks, GUID syntax validation and the logger fixes from 3.0.1. The manifest, loader, +**IT-ToolBox 3.3.0** contains the 33-command API documented below, including UPN +generation and availability checks alongside registry and GUID validation. The manifest, loader, command inventory and installable archive use the same supported function set. -See the [3.2.0 release notes](./CHANGELOG.md) for the changes and migration details. +See the [3.3.0 release notes](./CHANGELOG.md) for the changes and migration details. -Release builds should use the reviewed 3.2.0 commit or tag rather than a moving +Release builds should use the reviewed 3.3.0 commit or tag rather than a moving master checkout. Build and installation instructions are in [Packaging](./docs/Packaging.md). ## Status and requirements -The module has **31 exported functions**, matching the manifest and loader. +The module has **33 exported functions**, matching the manifest and loader. Automated tests cover the supported API; live AD, remote CIM and service authentication remain unverified. Requires PowerShell 7.4 or later (Core edition). Windows @@ -58,7 +58,10 @@ The module imports without WinSCP, GnuPG, Active Directory or Exchange dependencies. Most utilities run on Windows, Linux and macOS. `Test-IsRegistryKey` and `Test-RegistryValue` are Windows-only; remote `Get-OsUpTime` requires Windows CIM cmdlets and a reachable Windows endpoint. `Get-ReportChain` requires an available -`Get-ADUser` command and access to AD when invoked. Clipboard output requires a +`Get-ADUser` command and access to AD when invoked. `Test-UPNExist` and +`Get-UniqueUPN` also require AD commands at invocation; forest discovery uses +`Get-ADDomainController`, while domain lookup uses `Get-ADRootDSE`. +These dependencies do not prevent module import on macOS/Linux. Clipboard output requires a working platform clipboard backend. ## Quick start from a clone @@ -126,6 +129,8 @@ parameter help and the sections below for compatibility details. | `Test-IsUrl` | Check supported absolute URL syntax; does not fetch the URL. | | `Test-IsValidDn` | Check the documented practical DN syntax subset. | | `Test-IsValidUpn` | Check username and DNS/IDN suffix syntax; no directory lookup. | +| `Test-UPNExist` | Check an existing UPN in the selected forest or domain; requires AD commands. | +| `Get-UniqueUPN` | Find an available policy-valid UPN with bounded collision checks; requires AD commands. | | `Get-ReportChain` | Query transitive reports in AD; requires Get-ADUser and AD access. | | `New-StringEncryption` | Encrypt strings using the authenticated ITTB1 format and a passphrase. | | `New-StringDecryption` | Decrypt and authenticate ITTB1 ciphertext with a passphrase. | @@ -216,7 +221,7 @@ elapsed duration expressed in hours. These switches are mutually exclusive. - All commands formerly retained in `Staging/v3/` now have supported implementations. Its README records the migration. Exchange/AzureAD session wrappers and the global certificate-validation bypass have been removed; their source remains in Git history. -- Only the thirty-one listed commands are exported. Private helpers, variables and aliases +- Only the thirty-three listed commands are exported. Private helpers, variables and aliases are not exported. Do not treat v3 as a drop-in replacement for every v2 script: removed commands and documented parameter, output and encryption changes require migration. No restoration of removed service wrappers is promised. @@ -545,6 +550,26 @@ validators. Get-ReportChain requires an available Get-ADUser command and access an AD endpoint when invoked; it uses the command's ambient authentication. Tests mock AD queries and verify filter construction; no live domain query is tested. +### UPN generation and availability + +`Get-UniqueUPN` constructs a name and checks candidates through `Test-UPNExist`. +Both functions are available without an AD dependency at module import, but actual +availability checks require the ActiveDirectory commands and directory connectivity. +`Test-IsValidUpn` remains a local, cross-platform syntax check. + +```powershell +Test-IsValidUpn 'jose.garcia@example.com' +Get-UniqueUPN -FirstName Jose -LastName Garcia -UPNSuffix example.com -ADServer gc.example.com +Test-UPNExist -UPN 'jose.garcia@example.com' -SearchScope Domain -AdServer dc.example.com +``` + +Generation follows the same ASCII username policy as the validator. Unsupported +names or separators fail before any directory query; no automatic accent stripping +or transliteration is applied. IDN suffixes remain supported. Forest scope is the +default and uses a Global Catalog; domain scope is explicitly narrower. An +available name is a preflight result, not an atomic reservation. See +[UPN naming, scope and migration](./docs/UPN.md) for requirements and examples. + ## Script context Get-ScriptDirectory and Get-ScriptName resolve the immediately calling script rather diff --git a/Tests/IntegrationBoundary.Tests.ps1 b/Tests/IntegrationBoundary.Tests.ps1 index e0d09b5..d0fa19b 100644 --- a/Tests/IntegrationBoundary.Tests.ps1 +++ b/Tests/IntegrationBoundary.Tests.ps1 @@ -67,7 +67,7 @@ finally { $result.ErrorPreference | Should -Be 'Continue' $result.WarningPreference | Should -Be 'Continue' $result.LocationPreserved | Should -BeTrue - $result.ExportCount | Should -Be 31 + $result.ExportCount | Should -Be 33 $result.HistoricalExports | Should -Be 0 } } diff --git a/Tests/Module.Tests.ps1 b/Tests/Module.Tests.ps1 index 8499e9f..b953005 100644 --- a/Tests/Module.Tests.ps1 +++ b/Tests/Module.Tests.ps1 @@ -6,11 +6,11 @@ BeforeAll { Describe 'IT-ToolBox module boundary' { It 'has a valid manifest' { $manifest = Test-ModuleManifest $manifestPath -ErrorAction Stop - $manifest.Version | Should -Be ([version]'3.2.0') + $manifest.Version | Should -Be ([version]'3.3.0') } It 'exports exactly the supported commands' { - $expected = @('New-LogEntry', 'New-Timer', 'Get-TimerStatus', 'Stop-Timer', 'Get-ElapsedTime', 'Get-ScriptDirectory', 'Get-ScriptName', 'Test-FileName', 'Test-IsValidPath', 'Test-IsIP', 'Test-IsGuid', 'Test-IsDate', 'Test-IsEmail', 'Test-IsUrl', 'Convert-LogonTimestamp', 'Get-OsUpTime', 'Remove-SpecialCharacters', 'Test-IsRegistryKey', 'Test-RegistryValue', 'Test-IsValidDn', 'Test-IsValidUpn', 'Get-ReportChain', 'New-StringEncryption', 'New-StringDecryption', 'New-RandomString', 'New-RandomPassword', 'New-PhoneticPassword', 'New-ApiRequest', 'New-StringConversion', 'Get-StringCheckSum', 'Get-StringHashCode') | Sort-Object + $expected = @('New-LogEntry', 'New-Timer', 'Get-TimerStatus', 'Stop-Timer', 'Get-ElapsedTime', 'Get-ScriptDirectory', 'Get-ScriptName', 'Test-FileName', 'Test-IsValidPath', 'Test-IsIP', 'Test-IsGuid', 'Test-IsDate', 'Test-IsEmail', 'Test-IsUrl', 'Convert-LogonTimestamp', 'Get-OsUpTime', 'Remove-SpecialCharacters', 'Test-IsRegistryKey', 'Test-RegistryValue', 'Test-IsValidDn', 'Test-IsValidUpn', 'Test-UPNExist', 'Get-UniqueUPN', 'Get-ReportChain', 'New-StringEncryption', 'New-StringDecryption', 'New-RandomString', 'New-RandomPassword', 'New-PhoneticPassword', 'New-ApiRequest', 'New-StringConversion', 'Get-StringCheckSum', 'Get-StringHashCode') | Sort-Object $actual = @($module.ExportedFunctions.Keys | Sort-Object) ($actual -join ',') | Should -Be ($expected -join ',') $module.ExportedVariables.Count | Should -Be 0 diff --git a/Tests/Packaging.Tests.ps1 b/Tests/Packaging.Tests.ps1 index 6cca2ec..a336914 100644 --- a/Tests/Packaging.Tests.ps1 +++ b/Tests/Packaging.Tests.ps1 @@ -4,13 +4,13 @@ BeforeAll { $package = & $buildScript -OutputDirectory (Join-Path $TestDrive 'output [literal]') $extractRoot = Join-Path $TestDrive 'extracted' [IO.Compression.ZipFile]::ExtractToDirectory($package.ArchivePath, $extractRoot) - $moduleRoot = Join-Path $extractRoot 'IT-ToolBox/3.2.0' + $moduleRoot = Join-Path $extractRoot 'IT-ToolBox/3.3.0' } Describe 'Installable module packaging' { It 'uses the manifest release version and conventional module layout' { - $package.Version | Should -Be '3.2.0' - [IO.Path]::GetFileName($package.ArchivePath) | Should -Be 'IT-ToolBox-3.2.0.zip' + $package.Version | Should -Be '3.3.0' + [IO.Path]::GetFileName($package.ArchivePath) | Should -Be 'IT-ToolBox-3.3.0.zip' Test-ModuleManifest (Join-Path $moduleRoot 'IT-ToolBox.psd1') -ErrorAction Stop | Should -Not -BeNullOrEmpty foreach ($name in @('LICENSE', 'CONTRIBUTING.md', 'SECURITY.md')) { Test-Path -LiteralPath (Join-Path $moduleRoot $name) | Should -BeTrue @@ -45,7 +45,7 @@ Describe 'Installable module packaging' { $code = @" `$ErrorActionPreference = 'Stop' `$env:PSModulePath = '$escapedRoot' + [IO.Path]::PathSeparator + `$env:PSModulePath -`$module = Import-Module IT-ToolBox -RequiredVersion 3.2.0 -Force -PassThru +`$module = Import-Module IT-ToolBox -RequiredVersion 3.3.0 -Force -PassThru New-LogEntry -LogMessage 'Packaged logger' -BufferOnly [pscustomobject]@{ ExportCount = `$module.ExportedFunctions.Count @@ -59,7 +59,7 @@ New-LogEntry -LogMessage 'Packaged logger' -BufferOnly $LASTEXITCODE | Should -Be 0 $result = $output | ConvertFrom-Json $result.ModuleBase | Should -Be $moduleRoot - $result.ExportCount | Should -Be 31 + $result.ExportCount | Should -Be 33 $result.Buffer.Count | Should -Be 1 $result.Buffer[0] | Should -Match '\[INFO\]: Packaged logger$' } diff --git a/Tests/Upn.Tests.ps1 b/Tests/Upn.Tests.ps1 new file mode 100644 index 0000000..b3c0cf5 --- /dev/null +++ b/Tests/Upn.Tests.ps1 @@ -0,0 +1,195 @@ +BeforeAll { + Import-Module (Join-Path $PSScriptRoot '../IT-ToolBox.psd1') -Force -ErrorAction Stop + & (Get-Module IT-ToolBox) { + # Offline seams: accidental unmocked directory calls fail, never contact AD. + function script:Get-ADUser { [CmdletBinding()] param($LDAPFilter,$SearchScope,$SearchBase,$Server,$ResultSetSize) throw 'Unexpected directory call' } + function script:Get-ADRootDSE { [CmdletBinding()] param($Server) throw 'Unexpected root lookup' } + function script:Get-ADDomainController { [CmdletBinding()] param([switch]$Discover,$Service) throw 'Unexpected discovery' } + } +} + +Describe 'UPN generation' { + BeforeEach { Mock Test-UPNExist -ModuleName IT-ToolBox { $false } } + It 'uses full names by default' { + Get-UniqueUPN -FirstName John -LastName Doe -UPNSuffix example.com | Should -BeExactly 'john.doe@example.com' + } + It 'formats first and last names independently: /' -ForEach @( + @{ First='FullName';Last='FirstLetter';Expected='john.d@example.com' } + @{ First='FirstLetter';Last='FullName';Expected='j.doe@example.com' } + @{ First='FirstLetter';Last='FirstLetter';Expected='j.d@example.com' } + ) { + Get-UniqueUPN -FirstName John -LastName Doe -UPNSuffix example.com -FirstNameFormat $First -LastNameFormat $Last | Should -BeExactly $Expected + } + It 'includes an available middle name and permits an empty separator' { + Get-UniqueUPN -FirstName John -LastName Doe -MiddleName A -IncludeMiddleName -UPNSuffix example.com | Should -BeExactly 'john.a.doe@example.com' + Get-UniqueUPN -FirstName John -LastName Doe -Separator '' -UPNSuffix example.com | Should -BeExactly 'johndoe@example.com' + Get-UniqueUPN -FirstName John -LastName Doe -IncludeMiddleName -UPNSuffix example.com | Should -BeExactly 'john.doe@example.com' + } + It 'forwards server and scope using the real dependency parameter name' { + Get-UniqueUPN -FirstName John -LastName Doe -UPNSuffix example.com -Server dc.example.com -SearchScope Domain | Out-Null + Should -Invoke Test-UPNExist -ModuleName IT-ToolBox -Times 1 -Exactly -ParameterFilter { $AdServer -eq 'dc.example.com' -and $SearchScope -eq 'Domain' } + } + It 'appends counters until an unused candidate is found' { + Mock Test-UPNExist -ModuleName IT-ToolBox { $UPN -ne 'john.doe2@example.com' } + Get-UniqueUPN -FirstName John -LastName Doe -UPNSuffix example.com | Should -BeExactly 'john.doe2@example.com' + Should -Invoke Test-UPNExist -ModuleName IT-ToolBox -Times 3 -Exactly + } + It 'bounds retries and includes the unsuffixed candidate in MaxAttempts' { + Mock Test-UPNExist -ModuleName IT-ToolBox { $true } + { Get-UniqueUPN -FirstName John -LastName Doe -UPNSuffix example.com -MaxAttempts 2 } | Should -Throw '*after 2 candidates*' + Should -Invoke Test-UPNExist -ModuleName IT-ToolBox -Times 2 -Exactly + } + It 'propagates directory failures instead of returning availability' { + Mock Test-UPNExist -ModuleName IT-ToolBox { throw 'Directory offline' } + { Get-UniqueUPN -FirstName John -LastName Doe -UPNSuffix example.com } | Should -Throw '*Directory offline*' + } + It 'extracts an ADUser-shaped object' { + $user=[pscustomobject]@{GivenName='Jane';Surname='Doe';MiddleName='A'} + $user.PSObject.TypeNames.Insert(0,'Microsoft.ActiveDirectory.Management.ADUser') + Get-UniqueUPN -ADObject $user -UPNSuffix example.com -IncludeMiddleName | Should -BeExactly 'jane.a.doe@example.com' + } + It 'extracts directory property collections: ' -ForEach @( + @{Type='System.DirectoryServices.DirectoryEntry'} + @{Type='System.DirectoryServices.SearchResult'} + ) { + $user=[pscustomobject]@{Properties=@{givenName=@('Jane');sn=@('Doe');middleName=@('A')}} + $user.PSObject.TypeNames.Insert(0,$Type) + Get-UniqueUPN -ADObject $user -UPNSuffix example.com -IncludeMiddleName | Should -BeExactly 'jane.a.doe@example.com' + } + It 'rejects incomplete or unsupported objects before any lookup' { + {Get-UniqueUPN -ADObject ([pscustomobject]@{}) -UPNSuffix example.com} | Should -Throw '*Unsupported*' + $user=[pscustomobject]@{GivenName='Jane';Surname=$null} + $user.PSObject.TypeNames.Insert(0,'Microsoft.ActiveDirectory.Management.ADUser') + {Get-UniqueUPN -ADObject $user -UPNSuffix example.com} | Should -Throw '*nonblank*' + Should -Invoke Test-UPNExist -ModuleName IT-ToolBox -Times 0 -Exactly + } + It 'rejects invalid input before any lookup' { + {Get-UniqueUPN -FirstName ' ' -LastName Doe -UPNSuffix example.com} | Should -Throw + {Get-UniqueUPN -FirstName 'John Paul' -LastName Doe -UPNSuffix example.com} | Should -Throw + {Get-UniqueUPN -FirstName John -LastName Doe -UPNSuffix 'bad suffix'} | Should -Throw + {Get-UniqueUPN -FirstName John -LastName Doe -UPNSuffix example.com -Separator '@'} | Should -Throw + {Get-UniqueUPN -FirstName John -LastName Doe -UPNSuffix example.com -MaxAttempts 0} | Should -Throw + Should -Invoke Test-UPNExist -ModuleName IT-ToolBox -Times 0 -Exactly + } + It 'lowercases invariantly under Turkish culture' { + $old=[System.Globalization.CultureInfo]::CurrentCulture + try { + [System.Globalization.CultureInfo]::CurrentCulture=[System.Globalization.CultureInfo]::new('tr-TR') + Get-UniqueUPN -FirstName IVAN -LastName DOE -UPNSuffix EXAMPLE.COM | Should -BeExactly 'ivan.doe@example.com' + } finally { [System.Globalization.CultureInfo]::CurrentCulture=$old } + } +} + +Describe 'Directory query construction' { + BeforeEach { + Mock Get-ADUser -ModuleName IT-ToolBox { } + Mock Get-ADDomainController -ModuleName IT-ToolBox { [pscustomobject]@{HostName='gc.example.com'} } + Mock Get-ADRootDSE -ModuleName IT-ToolBox { [pscustomobject]@{DefaultNamingContext='DC=example,DC=com';DnsHostName='dc.example.com'} } + } + It 'runs the real generator and lookup together with mocked AD commands' { + Get-UniqueUPN -FirstName John -LastName Doe -UPNSuffix example.com -AdServer gc.example.com | Should -BeExactly 'john.doe@example.com' + Should -Invoke Get-ADUser -ModuleName IT-ToolBox -Times 1 -Exactly -ParameterFilter { $Server -eq 'gc.example.com:3268' -and $LDAPFilter -like '*john.doe@example.com*' } + } + It 'searches every GC partition by default' { + Test-UPNExist -UPN 'john@example.com' | Should -BeFalse + Should -Invoke Get-ADUser -ModuleName IT-ToolBox -Times 1 -Exactly -ParameterFilter { + $Server -eq 'gc.example.com:3268' -and $SearchBase -ceq '' -and $SearchScope -eq 'Subtree' -and $ResultSetSize -eq 1 -and + $LDAPFilter -eq '(&(objectCategory=person)(objectClass=user)(userPrincipalName=john@example.com))' + } + } + It 'accepts the Server alias without discovery and uses GC coverage' { + Test-UPNExist -UPN 'john@example.com' -Server gc.other.example | Should -BeFalse + Should -Invoke Get-ADDomainController -ModuleName IT-ToolBox -Times 0 -Exactly + Should -Invoke Get-ADUser -ModuleName IT-ToolBox -Times 1 -Exactly -ParameterFilter { $Server -eq 'gc.other.example:3268' -and $SearchBase -ceq '' } + } + It 'preserves an explicit secure Global Catalog port' { + Test-UPNExist -UPN 'john@example.com' -AdServer gc.example.com:3269 | Out-Null + Should -Invoke Get-ADUser -ModuleName IT-ToolBox -Times 1 -Exactly -ParameterFilter { $Server -eq 'gc.example.com:3269' } + } + It 'rejects a non-GC port in forest scope' { + {Test-UPNExist -UPN 'john@example.com' -AdServer dc.example.com:389} | Should -Throw '*Global Catalog port*' + Should -Invoke Get-ADUser -ModuleName IT-ToolBox -Times 0 -Exactly + } + It 'uses only the selected domain naming context' { + Test-UPNExist -UPN 'john@example.com' -SearchScope Domain -AdServer dc.other.example | Out-Null + Should -Invoke Get-ADRootDSE -ModuleName IT-ToolBox -Times 1 -Exactly -ParameterFilter {$Server -eq 'dc.other.example'} + Should -Invoke Get-ADUser -ModuleName IT-ToolBox -Times 1 -Exactly -ParameterFilter {$Server -eq 'dc.other.example' -and $SearchBase -eq 'DC=example,DC=com'} + } + It 'pins default domain lookup to the RootDSE controller' { + Test-UPNExist -UPN 'john@example.com' -SearchScope Domain | Out-Null + Should -Invoke Get-ADUser -ModuleName IT-ToolBox -Times 1 -Exactly -ParameterFilter {$Server -eq 'dc.example.com' -and $SearchBase -eq 'DC=example,DC=com'} + } + It 'returns exactly one Boolean when a user matches' { + Mock Get-ADUser -ModuleName IT-ToolBox {[pscustomobject]@{UserPrincipalName='john@example.com'}} + $result=@(Test-UPNExist -UPN 'john@example.com') + $result.Count | Should -Be 1 + $result[0] | Should -BeOfType ([bool]) + $result[0] | Should -BeTrue + } + It 'escapes literal wildcard, brackets, backslash, NUL and UTF-8 bytes' { + $inputUpn="a*(b)\$([char]0)é@example.com" + Test-UPNExist -UPN $inputUpn -AdServer gc.example.com | Out-Null + Should -Invoke Get-ADUser -ModuleName IT-ToolBox -Times 1 -Exactly -ParameterFilter { + $LDAPFilter -eq '(&(objectCategory=person)(objectClass=user)(userPrincipalName=a\2a\28b\29\5c\00\c3\a9@example.com))' + } + } + It 'propagates directory errors' { + Mock Get-ADUser -ModuleName IT-ToolBox {throw 'Access denied'} + {Test-UPNExist -UPN 'john@example.com'} | Should -Throw '*Access denied*' + } + It 'rejects blank discovery and domain results' { + Mock Get-ADDomainController -ModuleName IT-ToolBox {[pscustomobject]@{HostName=''}} + {Test-UPNExist -UPN 'john@example.com'} | Should -Throw '*no hostname*' + Mock Get-ADRootDSE -ModuleName IT-ToolBox {[pscustomobject]@{DefaultNamingContext=''}} + {Test-UPNExist -UPN 'john@example.com' -SearchScope Domain} | Should -Throw '*no default naming context*' + } + It 'rejects missing ActiveDirectory commands clearly' { + Mock Get-Command -ModuleName IT-ToolBox { $null } -ParameterFilter {$Name -eq 'Get-ADUser'} + {Test-UPNExist -UPN 'john@example.com'} | Should -Throw '*requires Get-ADUser*' + } +} + +Describe 'UPN generation policy alignment' { + BeforeEach { Mock Test-UPNExist -ModuleName IT-ToolBox { $false } } + It 'rejects unsupported generated names before directory lookup: ' -ForEach @( + @{First='José';Last='García';Separator='.'} + @{First='.John';Last='Doe';Separator='.'} + @{First='John.';Last='Doe';Separator='.'} + @{First='John';Last='Doe';Separator='/'} + @{First='John';Last='Doe';Separator='..'} + ) { + { Get-UniqueUPN -FirstName $First -LastName $Last -Separator $Separator -UPNSuffix example.com } | Should -Throw '*supported syntax policy*' + Should -Invoke Test-UPNExist -ModuleName IT-ToolBox -Times 0 -Exactly + } + It 'preserves supported punctuation and IDN suffixes without transliteration' { + $candidate=Get-UniqueUPN -FirstName Anne-Marie -LastName "O'Brien" -UPNSuffix 'bücher.example' + $candidate | Should -BeExactly "anne-marie.o'brien@bücher.example" + Test-IsValidUpn $candidate | Should -BeTrue + } + It 'accepts explicitly normalized names and returns a policy-valid collision candidate' { + Mock Test-UPNExist -ModuleName IT-ToolBox { $UPN -ne 'jose.garcia2@example.com' } + $candidate=Get-UniqueUPN -FirstName Jose -LastName Garcia -UPNSuffix example.com + $candidate | Should -BeExactly 'jose.garcia2@example.com' + Test-IsValidUpn $candidate | Should -BeTrue + } + It 'does not require an optional ADUser middle-name property under StrictMode' { + $user=[pscustomobject]@{GivenName='Jane';Surname='Doe'} + $user.PSObject.TypeNames.Insert(0,'Microsoft.ActiveDirectory.Management.ADUser') + & { + Set-StrictMode -Version Latest + Get-UniqueUPN -ADObject $user -IncludeMiddleName -UPNSuffix example.com + } | Should -BeExactly 'jane.doe@example.com' + } +} +Describe 'UPN explicit server port validation' { + It 'rejects an invalid numeric port without a directory call: ' -ForEach @( + @{Port='0'}; @{Port='65536'}; @{Port='99999'} + ) { + Mock Get-ADUser -ModuleName IT-ToolBox { throw 'Unexpected directory call' } + { Test-UPNExist -UPN 'john@example.com' -SearchScope Domain -AdServer "dc.example.com:$Port" } | Should -Throw + { Get-UniqueUPN -FirstName John -LastName Doe -UPNSuffix example.com -AdServer "dc.example.com:$Port" } | Should -Throw + Should -Invoke Get-ADUser -ModuleName IT-ToolBox -Times 0 -Exactly + } +} + +AfterAll { Remove-Module IT-ToolBox -ErrorAction SilentlyContinue } diff --git a/docs/Packaging.md b/docs/Packaging.md index c5df513..fc8d61e 100644 --- a/docs/Packaging.md +++ b/docs/Packaging.md @@ -1,7 +1,7 @@ # Build and install the module -Build from the reviewed 3.2.0 release commit or tag using PowerShell 7.4 or later. -The manifest version is 3.2.0, with no prerelease suffix. Preserve that source +Build from the reviewed 3.3.0 release commit or tag using PowerShell 7.4 or later. +The manifest version is 3.3.0, with no prerelease suffix. Preserve that source revision alongside the archive when distributing it. Run from the repository root: @@ -11,12 +11,12 @@ Invoke-Pester ./Tests ./scripts/Build-Module.ps1 ``` -The script creates artifacts/IT-ToolBox-3.2.0.zip and returns its path, +The script creates artifacts/IT-ToolBox-3.3.0.zip and returns its path, version and file count. It takes the version from the manifest without modifying it. Use -OutputDirectory to choose another local destination. An existing archive causes a terminating error; use a different destination for another build. -The ZIP contains IT-ToolBox/3.2.0/ with the manifest, loader, Public and Private +The ZIP contains IT-ToolBox/3.3.0/ with the manifest, loader, Public and Private implementation files, license, README, changelog, contributing guide, security policy and Markdown documents in docs/. Tests, build scripts, Legacy, Staging, Git metadata and external binaries are @@ -26,8 +26,8 @@ in a separate session; the old decoder is not distributed in this archive. Extract into a new directory, then verify or import the extracted manifest: ```powershell -Expand-Archive -LiteralPath './artifacts/IT-ToolBox-3.2.0.zip' -DestinationPath './module-install' -Import-Module './module-install/IT-ToolBox/3.2.0/IT-ToolBox.psd1' -Force +Expand-Archive -LiteralPath './artifacts/IT-ToolBox-3.3.0.zip' -DestinationPath './module-install' +Import-Module './module-install/IT-ToolBox/3.3.0/IT-ToolBox.psd1' -Force Get-Command -Module IT-ToolBox ``` diff --git a/docs/UPN.md b/docs/UPN.md new file mode 100644 index 0000000..d7dc793 --- /dev/null +++ b/docs/UPN.md @@ -0,0 +1,81 @@ +# UPN availability and generation + +## Requirements and loading + +Import IT-ToolBox.psd1 on PowerShell 7.4+; no ActiveDirectory module is required at +import. The module loader supplies the functions and private helpers. These UPN +commands are exported in 3.3.0; keep private helpers internal. + +Actual lookup uses Get-ADUser from the ActiveDirectory module (for example, an appropriately configured Windows management host with RSAT). Forest discovery also needs Get-ADDomainController; Domain scope needs Get-ADRootDSE. Dependencies are checked at invocation, so scripts load and offline tests run without the AD module. This replaces the historical System.DirectoryServices query and is a deliberate new dependency. Live operation from macOS/Linux is not asserted by the offline tests. + +## Generation + +```powershell +Import-Module ./IT-ToolBox.psd1 +Get-UniqueUPN -FirstName John -LastName Doe -UPNSuffix example.com -ADServer gc.example.com +# john.doe@example.com, if no matching user is found + +Get-UniqueUPN -FirstName John -LastName Doe -UPNSuffix example.com -FirstNameFormat FirstLetter -LastNameFormat FullName -ADServer gc.example.com +# j.doe@example.com, if available +``` + +FullName is the corrected default. Each formatting option applies independently. IncludeMiddleName inserts the supplied/extracted middle name when nonblank. Outer name whitespace is trimmed. Embedded whitespace, controls and @ are rejected in name components; normalize compound names to your organization's policy before calling. Separators may be empty but cannot contain whitespace, controls or @. The suffix must pass IT-ToolBox's DNS/IDN syntax policy; syntax validation does not check registered AD UPN suffixes. + +ADObject accepts ADUser, DirectoryEntry and SearchResult inputs as before. Ensure required name attributes are loaded; missing first/last names terminate before querying. The tests use representative typed objects, not a live directory. Casing uses ToLowerInvariant; initials use the first .NET text element. + +Collisions try localpart@suffix, localpart1@suffix, localpart2@suffix, and so on. MaxAttempts defaults to 1000 and includes the initial unsuffixed candidate. Exhaustion throws. The generator calls the public lookup for each candidate, forwarding server and scope. Supply a specific controller to keep candidate checks on a chosen server and avoid repeated discovery. Name normalization and AD provisioning policy remain caller responsibilities. + +## Lookup scope + +Forest is the default. An explicit hostname selects that host on Global Catalog port 3268, or an explicit 3268/3269 port is retained. The host must actually provide the requested GC service; failure is not treated as absence. An empty SearchBase searches all GC partitions. Without a server, discover a Global Catalog in the current forest. + +```powershell +Test-UPNExist -UPN 'john@example.com' -AdServer gc.example.com -SearchScope Forest +Test-UPNExist -UPN 'john@example.com' -Server gc.example.com:3269 +Test-UPNExist -UPN 'john@example.com' -AdServer dc.example.com -SearchScope Domain +``` + +Server is a compatibility alias for AdServer on both functions. Domain scope uses the selected controller's DefaultNamingContext, with a subtree search. When no server is supplied, pin the user query to the RootDSE controller used to discover that naming context. Domain coverage must not be described as forest-wide. Hostname/optional-port strings are supported; LDAP URLs, arbitrary search roots and IPv6 literals are not accepted by this parameter contract. + +The internal Get-ADUser query uses an escaped LDAP userPrincipalName equality filter, restricts results to person/user objects, limits results to one, and returns one Boolean. AD command/discovery failures terminate. LDAP escaping reuses IT-ToolBox's ConvertTo-ITToolBoxLdapFilterValue implementation unchanged. It is filter-value escaping, not DN escaping. + +Microsoft documents the empty SearchBase / Global Catalog behavior here: +https://learn.microsoft.com/en-us/powershell/module/activedirectory/get-aduser + +## Limits and production validation + +An unused UPN is a point-in-time observation, not a reservation. Replication delays, concurrent provisioning and scoped directory permissions affect what can be observed. Handle collision errors in account creation and retry according to your provisioning workflow. Domain scope only checks that domain; alternate credentials and custom search roots are not added by this patch. + +Tests mock AD commands and validate request assembly, naming, errors and output contracts. Before production, check with a test forest: known-present/absent users, collisions, two domains, explicit GC and domain controllers, discovery, port 3269 if used, permissions and connectivity failures. Perform provisioning validation only with approved test accounts; the functions themselves do not create or modify users. + +## Consistent naming policy + +Every generated candidate must satisfy Test-IsValidUpn, using the same implementation +as IT-ToolBox: ASCII letters/digits at the username edges; dots, underscores, +hyphens and apostrophes internally; no consecutive dots; and a DNS/IDN suffix. +The initial candidate is validated before any directory call. Numeric collision +suffixes preserve that syntax policy. + +This deliberately changes older generation behavior. For example, José/García +and separators such as / or .. now cause a policy error. Normalize names according +to your organization's rules before calling, for example explicitly supplying +Jose and Garcia. No silent accent stripping or custom-map conversion is performed. +Unicode AD identities are not intrinsically invalid; this is the toolkit's +chosen generation policy. Test-UPNExist still searches literal existing identities, +including Unicode names, without imposing the generation policy on the lookup. + +An absent optional ADUser MiddleName property is treated as empty, including under +StrictMode. Explicit server ports must be within 1–65535; forest scope additionally +requires port 3268 or 3269. Existing aliases, bounded retries and scope defaults remain. + +Test-IsValidUpn is available for local cross-platform validation after importing the module: + +```powershell +Import-Module ./IT-ToolBox.psd1 +Test-IsValidUpn 'first.last@example.com' +``` + +The validator +does not need AD, perform discovery, check configured suffixes or establish uniqueness. +The Get-UniqueUPN command still performs a directory lookup: it does not claim an +unchecked generated string is unique when AD is unavailable.