diff --git a/docs/reference/cmdlets/New-IdleAuthSession.md b/docs/reference/cmdlets/New-IdleAuthSession.md index dbcadfa8..f331e309 100644 --- a/docs/reference/cmdlets/New-IdleAuthSession.md +++ b/docs/reference/cmdlets/New-IdleAuthSession.md @@ -13,7 +13,7 @@ Creates a simple AuthSessionBroker for use with IdLE providers. ## SYNTAX ``` -New-IdleAuthSession [-SessionMap] <Hashtable> [[-DefaultAuthSession] <Object>] [-AuthSessionType] <String> +New-IdleAuthSession [[-SessionMap] <Hashtable>] [[-DefaultAuthSession] <Object>] [-AuthSessionType] <String> [-ProgressAction <ActionPreference>] [<CommonParameters>] ``` @@ -43,7 +43,7 @@ Type: Hashtable Parameter Sets: (All) Aliases: -Required: True +Required: False Position: 1 Default value: None Accept pipeline input: False diff --git a/src/IdLE.Core/Public/Invoke-IdleProviderMethod.ps1 b/src/IdLE.Core/Public/Invoke-IdleProviderMethod.ps1 index e750241a..11540f7c 100644 --- a/src/IdLE.Core/Public/Invoke-IdleProviderMethod.ps1 +++ b/src/IdLE.Core/Public/Invoke-IdleProviderMethod.ps1 @@ -11,7 +11,9 @@ function Invoke-IdleProviderMethod { provider methods with proper authentication handling. Key features: - - Acquires auth sessions via Context.AcquireAuthSession when With.AuthSessionName is present + - Acquires auth sessions via Context.AcquireAuthSession when AuthSessionBroker is available + - If With.AuthSessionName is present, uses it for session routing + - If With.AuthSessionName is absent but broker exists, attempts to acquire default session - Detects whether provider methods support AuthSession parameter (backwards compatible) - Passes AuthSession to provider methods that support it - Validates provider existence and method implementation @@ -70,17 +72,15 @@ function Invoke-IdleProviderMethod { # Auth session acquisition (optional, data-only) $authSession = $null - if ($With.ContainsKey('AuthSessionName')) { - $sessionName = [string]$With.AuthSessionName - $sessionOptions = if ($With.ContainsKey('AuthSessionOptions')) { $With.AuthSessionOptions } else { $null } - + + # Validate AuthSessionOptions early (regardless of broker availability) + if ($With.ContainsKey('AuthSessionOptions')) { + $sessionOptions = $With.AuthSessionOptions if ($null -ne $sessionOptions -and -not ($sessionOptions -is [hashtable])) { throw "With.AuthSessionOptions must be a hashtable or null." } - - $authSession = $Context.AcquireAuthSession($sessionName, $sessionOptions) } - + $provider = $Context.Providers[$ProviderAlias] # Check if provider method exists @@ -91,6 +91,42 @@ function Invoke-IdleProviderMethod { # Check if method supports AuthSession parameter $supportsAuthSession = Test-IdleProviderMethodParameter -ProviderMethod $providerMethod -ParameterName 'AuthSession' + + # Check if context can acquire auth sessions + $canAcquireAuth = $Context.PSObject.Methods.Name -contains 'AcquireAuthSession' + + # Only acquire auth session if provider method can use it or if explicitly requested + $shouldAcquireAuth = $With.ContainsKey('AuthSessionName') -or ($supportsAuthSession -and $canAcquireAuth) + + if ($shouldAcquireAuth) { + if (-not $canAcquireAuth) { + # AuthSessionName was explicitly set but context cannot acquire sessions + throw "With.AuthSessionName is set to '$($With.AuthSessionName)' but Context does not have an AcquireAuthSession method." + } + + # If AuthSessionName is provided, use it for routing + if ($With.ContainsKey('AuthSessionName')) { + $sessionName = [string]$With.AuthSessionName + $sessionOptions = if ($With.ContainsKey('AuthSessionOptions')) { $With.AuthSessionOptions } else { $null } + + $authSession = $Context.AcquireAuthSession($sessionName, $sessionOptions) + } + else { + # No AuthSessionName provided but provider supports auth - try to acquire default session + # Use empty string to signal default request without conflicting with user session names + try { + $authSession = $Context.AcquireAuthSession('', $null) + } + catch { + # If provider method supports AuthSession and default acquisition fails, rethrow + if ($supportsAuthSession) { + throw + } + # Otherwise, continue without auth session for backward compatibility + $authSession = $null + } + } + } # Call provider method with appropriate signature if ($supportsAuthSession -and $null -ne $authSession) { diff --git a/src/IdLE.Core/Public/New-IdleAuthSessionBroker.ps1 b/src/IdLE.Core/Public/New-IdleAuthSessionBroker.ps1 index 5462595f..09410fdf 100644 --- a/src/IdLE.Core/Public/New-IdleAuthSessionBroker.ps1 +++ b/src/IdLE.Core/Public/New-IdleAuthSessionBroker.ps1 @@ -13,22 +13,28 @@ function New-IdleAuthSessionBroker { AcquireAuthSession method. .PARAMETER SessionMap - A hashtable that maps session configurations to auth sessions. Each key is a hashtable + Optional hashtable that maps session configurations to auth sessions. Each key is a hashtable representing the AuthSessionOptions pattern, and each value is the auth session to return. The value can be a PSCredential, token string, session object, or any object appropriate for the AuthSessionType. - Common patterns: - - @{ Role = 'Tier0' } -> $tier0Credential (for Credential type) - - @{ Role = 'Admin' } -> $adminToken (for OAuth type) - - @{ Server = 'Server01' } -> $remoteSession (for PSRemoting type) - - @{ Environment = 'Production' } -> $prodCred + Keys can include AuthSessionName for name-based routing: + - @{ AuthSessionName = 'AD'; Role = 'ADAdm' } -> $admAD (AuthSessionName + Role routing) + - @{ AuthSessionName = 'EXO' } -> $exoToken (AuthSessionName-only routing) + - @{ Role = 'Tier0' } -> $tier0Credential (Options-only routing, legacy support) + - @{ Server = 'AADConnect01' } -> $remoteSession (for PSRemoting scenarios) + - @{ Domain = 'SourceAD' } -> $sourceCred (for multi-forest scenarios) + - @{ Environment = 'Production' } -> $prodCred (for environment-specific routing) + + SessionMap is optional if DefaultAuthSession is provided. .PARAMETER DefaultAuthSession Optional default auth session to return when no session options are provided or when the options don't match any entry in SessionMap. Can be a PSCredential, token string, session object, or any object appropriate for the AuthSessionType. + At least one of SessionMap or DefaultAuthSession must be provided. + .PARAMETER AuthSessionType Specifies the type of authentication session. This determines validation rules, lifecycle management, and telemetry behavior. @@ -39,17 +45,21 @@ function New-IdleAuthSessionBroker { - 'Credential': Credential-based authentication (e.g., Active Directory, mock providers) .EXAMPLE - # Simple role-based broker with Credential session type - $broker = New-IdleAuthSessionBroker -SessionMap @{ - @{ Role = 'Tier0' } = $tier0Credential - @{ Role = 'Admin' } = $adminCredential - } -DefaultAuthSession $adminCredential -AuthSessionType 'Credential' + # Simple single-credential broker (no SessionMap required) + $broker = New-IdleAuthSessionBroker -DefaultAuthSession $admCred -AuthSessionType 'Credential' $plan = New-IdlePlan -WorkflowPath './workflow.psd1' -Request $request -Providers @{ Identity = New-IdleADIdentityProvider AuthSessionBroker = $broker } + .EXAMPLE + # AuthSessionName-based routing with roles + $broker = New-IdleAuthSessionBroker -SessionMap @{ + @{ AuthSessionName = 'AD'; Role = 'ADAdm' } = $tier0Credential + @{ AuthSessionName = 'AD'; Role = 'ADRead' } = $readOnlyCredential + } -DefaultAuthSession $adminCredential -AuthSessionType 'Credential' + .EXAMPLE # OAuth broker with token strings $broker = New-IdleAuthSessionBroker -SessionMap @{ @@ -69,13 +79,21 @@ function New-IdleAuthSessionBroker { @{ Server = 'AADConnect01' } = $remoteSessionCred } -AuthSessionType 'PSRemoting' + .EXAMPLE + # Environment-based routing for multi-environment scenarios + $broker = New-IdleAuthSessionBroker -SessionMap @{ + @{ Environment = 'Production' } = $prodCred + @{ Environment = 'Test' } = $testCred + } -DefaultAuthSession $devCred -AuthSessionType 'Credential' + .OUTPUTS PSCustomObject with AcquireAuthSession method #> [CmdletBinding()] param( - [Parameter(Mandatory)] - [ValidateNotNull()] + [Parameter()] + [AllowNull()] + [AllowEmptyCollection()] [hashtable] $SessionMap, [Parameter()] @@ -87,6 +105,11 @@ function New-IdleAuthSessionBroker { [string] $AuthSessionType ) + # Validate: If SessionMap is empty/null, DefaultAuthSession must be provided + if (($null -eq $SessionMap -or $SessionMap.Count -eq 0) -and $null -eq $DefaultAuthSession) { + throw "SessionMap is empty or null. DefaultAuthSession must be provided when SessionMap is not used." + } + $broker = [pscustomobject]@{ PSTypeName = 'IdLE.AuthSessionBroker' SessionMap = $SessionMap @@ -97,7 +120,7 @@ function New-IdleAuthSessionBroker { $broker | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { param( [Parameter(Mandatory)] - [ValidateNotNullOrEmpty()] + [AllowEmptyString()] [string] $Name, [Parameter()] @@ -105,10 +128,6 @@ function New-IdleAuthSessionBroker { [hashtable] $Options ) - # $Name is part of the broker contract but not used in this simple implementation - # This broker routes based on Options only; custom brokers may use Name for additional routing - $null = $Name - # TODO: Implement type-specific validation rules for AuthSessionType # Current implementation allows all options for all session types # Future enhancements may add: @@ -116,40 +135,126 @@ function New-IdleAuthSessionBroker { # - PSRemoting: Validate remote session state, connectivity # - Credential: Validate credential format, domain membership - # If no options provided, return default - if ($null -eq $Options -or $Options.Count -eq 0) { + # Empty string signals default session request + if ([string]::IsNullOrEmpty($Name)) { + if ($null -ne $this.DefaultAuthSession) { + return $this.DefaultAuthSession + } + throw "No default auth session configured." + } + + # If SessionMap is null or empty, return default + if ($null -eq $this.SessionMap -or $this.SessionMap.Count -eq 0) { if ($null -ne $this.DefaultAuthSession) { return $this.DefaultAuthSession } - throw "No auth session options provided and no default auth session configured." + throw "No SessionMap configured and no default auth session available." } - # Find matching session in map + # Matching logic: + # 1. If Name provided: try to match entries with AuthSessionName key + # 2. If Options provided: match all key/value pairs + # 3. Fall back to DefaultAuthSession + # 4. Fail with clear error + + $authSessionNameMatches = @() + $legacyMatches = @() + foreach ($entry in $this.SessionMap.GetEnumerator()) { $pattern = $entry.Key - $credential = $entry.Value - - # Check if all keys in pattern match Options - $matches = $true - foreach ($key in $pattern.Keys) { - if (-not $Options.ContainsKey($key) -or $Options[$key] -ne $pattern[$key]) { - $matches = $false - break + + # Check if pattern includes AuthSessionName + if ($pattern.ContainsKey('AuthSessionName')) { + # AuthSessionName must match + if ($pattern.AuthSessionName -ne $Name) { + continue + } + + # If pattern has ONLY AuthSessionName (no other keys) + if ($pattern.Keys.Count -eq 1) { + # Only match if Options is null or empty + if ($null -eq $Options -or $Options.Count -eq 0) { + $authSessionNameMatches += $entry + } + continue + } + + # Pattern has additional keys beyond AuthSessionName + # All other keys in pattern must match Options (if Options provided) + $matches = $true + foreach ($key in $pattern.Keys) { + if ($key -eq 'AuthSessionName') { + continue # Already checked + } + + # If Options is null or doesn't contain the key, no match + if ($null -eq $Options -or -not $Options.ContainsKey($key) -or $Options[$key] -ne $pattern[$key]) { + $matches = $false + break + } + } + + if ($matches) { + $authSessionNameMatches += $entry } } - - if ($matches) { - return $credential + else { + # Legacy: pattern without AuthSessionName - match based on Options only + if ($null -eq $Options -or $Options.Count -eq 0) { + continue # No options to match + } + + $matches = $true + foreach ($key in $pattern.Keys) { + if (-not $Options.ContainsKey($key) -or $Options[$key] -ne $pattern[$key]) { + $matches = $false + break + } + } + + if ($matches) { + $legacyMatches += $entry + } } } - # No match found + # Prioritize AuthSessionName-based matches over legacy matches + $matchingEntries = @() + if (@($authSessionNameMatches).Count -gt 0) { + $matchingEntries = @($authSessionNameMatches) + } else { + $matchingEntries = @($legacyMatches) + } + + # Return first match if exactly one found + if ($matchingEntries.Count -eq 1) { + return $matchingEntries[0].Value + } + + # If multiple matches, this is ambiguous - fail with clear error + if ($matchingEntries.Count -gt 1) { + $matchDetails = ($matchingEntries | ForEach-Object { + $currentEntry = $_ + $keyStr = ($currentEntry.Key.Keys | ForEach-Object { "$_=$($currentEntry.Key[$_])" }) -join ', ' + "{ $keyStr }" + }) -join '; ' + throw "Ambiguous auth session match for Name='$Name'. Multiple entries matched: $matchDetails. Provide AuthSessionOptions to disambiguate." + } + + # No match found - fall back to default if ($null -ne $this.DefaultAuthSession) { return $this.DefaultAuthSession } - $optionsStr = ($Options.Keys | ForEach-Object { "$_=$($Options[$_])" }) -join ', ' - throw "No matching auth session found for options: $optionsStr" + # No match and no default + $nameStr = "Name='$Name'" + $optionsPart = if ($null -ne $Options -and $Options.Count -gt 0) { + $optsStr = ($Options.Keys | ForEach-Object { "$_=$($Options[$_])" }) -join ', ' + ", Options={ $optsStr }" + } else { + "" + } + throw "No matching auth session found for $nameStr$optionsPart and no default auth session configured." } -Force return $broker diff --git a/src/IdLE/Public/New-IdleAuthSession.ps1 b/src/IdLE/Public/New-IdleAuthSession.ps1 index 434b8ee2..5f5b3fd8 100644 --- a/src/IdLE/Public/New-IdleAuthSession.ps1 +++ b/src/IdLE/Public/New-IdleAuthSession.ps1 @@ -46,8 +46,9 @@ function New-IdleAuthSession { #> [CmdletBinding()] param( - [Parameter(Mandatory)] - [ValidateNotNull()] + [Parameter()] + [AllowNull()] + [AllowEmptyCollection()] [hashtable] $SessionMap, [Parameter()] @@ -61,9 +62,11 @@ function New-IdleAuthSession { # Delegate to IdLE.Core implementation. $params = @{ - SessionMap = $SessionMap AuthSessionType = $AuthSessionType } + if ($PSBoundParameters.ContainsKey('SessionMap')) { + $params['SessionMap'] = $SessionMap + } if ($PSBoundParameters.ContainsKey('DefaultAuthSession')) { $params['DefaultAuthSession'] = $DefaultAuthSession } diff --git a/tests/Core/New-IdleAuthSession.Tests.ps1 b/tests/Core/New-IdleAuthSession.Tests.ps1 index 064d04c9..1518768b 100644 --- a/tests/Core/New-IdleAuthSession.Tests.ps1 +++ b/tests/Core/New-IdleAuthSession.Tests.ps1 @@ -166,4 +166,158 @@ Describe 'New-IdleAuthSession' { $session | Should -Not -BeNullOrEmpty } } + + Context 'Optional SessionMap' { + It 'creates broker with only DefaultAuthSession (no SessionMap)' { + $broker = New-IdleAuthSession -DefaultAuthSession $testCred -AuthSessionType 'Credential' + + $broker | Should -Not -BeNullOrEmpty + $broker.DefaultAuthSession | Should -Not -BeNullOrEmpty + $broker.SessionMap | Should -BeNullOrEmpty + } + + It 'returns DefaultAuthSession when SessionMap is null' { + $broker = New-IdleAuthSession -DefaultAuthSession $testCred -AuthSessionType 'Credential' + + $session = $broker.AcquireAuthSession('AnyName', $null) + + $session | Should -Not -BeNullOrEmpty + $session.UserName | Should -Be 'TestUser' + } + + It 'returns DefaultAuthSession when SessionMap is empty' { + $broker = New-IdleAuthSession -SessionMap @{} -DefaultAuthSession $testCred -AuthSessionType 'Credential' + + $session = $broker.AcquireAuthSession('AnyName', $null) + + $session | Should -Not -BeNullOrEmpty + $session.UserName | Should -Be 'TestUser' + } + + It 'throws when SessionMap is null and DefaultAuthSession is not provided' { + { + New-IdleAuthSession -SessionMap $null -AuthSessionType 'Credential' + } | Should -Throw '*DefaultAuthSession must be provided*' + } + + It 'throws when SessionMap is empty and DefaultAuthSession is not provided' { + { + New-IdleAuthSession -SessionMap @{} -AuthSessionType 'Credential' + } | Should -Throw '*DefaultAuthSession must be provided*' + } + } + + Context 'AuthSessionName-based routing' { + BeforeEach { + $password1 = ConvertTo-SecureString 'Password1!' -AsPlainText -Force + $cred1 = New-Object System.Management.Automation.PSCredential('ADAdm', $password1) + + $password2 = ConvertTo-SecureString 'Password2!' -AsPlainText -Force + $cred2 = New-Object System.Management.Automation.PSCredential('EXOAdm', $password2) + + $password3 = ConvertTo-SecureString 'Password3!' -AsPlainText -Force + $cred3 = New-Object System.Management.Automation.PSCredential('ADRead', $password3) + } + + It 'matches AuthSessionName without options' { + $broker = New-IdleAuthSession -SessionMap @{ + @{ AuthSessionName = 'AD' } = $cred1 + @{ AuthSessionName = 'EXO' } = $cred2 + } -AuthSessionType 'Credential' + + $session = $broker.AcquireAuthSession('AD', $null) + + $session | Should -Not -BeNullOrEmpty + $session.UserName | Should -Be 'ADAdm' + } + + It 'matches AuthSessionName with matching options' { + $broker = New-IdleAuthSession -SessionMap @{ + @{ AuthSessionName = 'AD'; Role = 'ADAdm' } = $cred1 + @{ AuthSessionName = 'AD'; Role = 'ADRead' } = $cred3 + } -AuthSessionType 'Credential' + + $session = $broker.AcquireAuthSession('AD', @{ Role = 'ADRead' }) + + $session | Should -Not -BeNullOrEmpty + $session.UserName | Should -Be 'ADRead' + } + + It 'falls back to default when AuthSessionName does not match' { + $broker = New-IdleAuthSession -SessionMap @{ + @{ AuthSessionName = 'AD' } = $cred1 + } -DefaultAuthSession $testCred -AuthSessionType 'Credential' + + $session = $broker.AcquireAuthSession('EXO', $null) + + $session | Should -Not -BeNullOrEmpty + $session.UserName | Should -Be 'TestUser' + } + + It 'throws when AuthSessionName matches multiple entries (ambiguous)' { + $broker = New-IdleAuthSession -SessionMap @{ + @{ AuthSessionName = 'AD' } = $cred1 + @{ AuthSessionName = 'AD' } = $cred3 + } -AuthSessionType 'Credential' + + { $broker.AcquireAuthSession('AD', $null) } | + Should -Throw '*Ambiguous*' + } + + It 'prefers AuthSessionName match over legacy Options-only match' { + $broker = New-IdleAuthSession -SessionMap @{ + @{ Role = 'Admin' } = $testCred + @{ AuthSessionName = 'AD'; Role = 'Admin' } = $cred1 + } -AuthSessionType 'Credential' + + $session = $broker.AcquireAuthSession('AD', @{ Role = 'Admin' }) + + $session | Should -Not -BeNullOrEmpty + $session.UserName | Should -Be 'ADAdm' + } + + It 'supports legacy Options-only routing when AuthSessionName is not in pattern' { + $broker = New-IdleAuthSession -SessionMap @{ + @{ Role = 'Tier0' } = $cred1 + @{ Role = 'Admin' } = $cred2 + } -AuthSessionType 'Credential' + + $session = $broker.AcquireAuthSession('AnyName', @{ Role = 'Admin' }) + + $session | Should -Not -BeNullOrEmpty + $session.UserName | Should -Be 'EXOAdm' + } + + It 'throws when AuthSessionName does not match and no default provided' { + $broker = New-IdleAuthSession -SessionMap @{ + @{ AuthSessionName = 'AD' } = $cred1 + } -AuthSessionType 'Credential' + + { $broker.AcquireAuthSession('EXO', $null) } | + Should -Throw '*No matching auth session found*' + } + + It 'matches complex pattern: AuthSessionName + multiple options' { + $broker = New-IdleAuthSession -SessionMap @{ + @{ AuthSessionName = 'AD'; Role = 'Admin'; Environment = 'Prod' } = $cred1 + } -AuthSessionType 'Credential' + + $session = $broker.AcquireAuthSession('AD', @{ Role = 'Admin'; Environment = 'Prod' }) + + $session | Should -Not -BeNullOrEmpty + $session.UserName | Should -Be 'ADAdm' + } + + It 'does not match when partial options provided' { + $broker = New-IdleAuthSession -SessionMap @{ + @{ AuthSessionName = 'AD'; Role = 'Admin'; Environment = 'Prod' } = $cred1 + } -DefaultAuthSession $testCred -AuthSessionType 'Credential' + + # Only providing Role, not Environment - should fall back to default + $session = $broker.AcquireAuthSession('AD', @{ Role = 'Admin' }) + + $session | Should -Not -BeNullOrEmpty + $session.UserName | Should -Be 'TestUser' + } + } } diff --git a/tests/Steps/Invoke-IdleStepAuthSession.Tests.ps1 b/tests/Steps/Invoke-IdleStepAuthSession.Tests.ps1 index 0640054b..69d488be 100644 --- a/tests/Steps/Invoke-IdleStepAuthSession.Tests.ps1 +++ b/tests/Steps/Invoke-IdleStepAuthSession.Tests.ps1 @@ -294,5 +294,109 @@ Describe 'IdLE.Steps - Auth Session Routing' { { Invoke-IdleStepEnsureAttribute -Context $context -Step $step } | Should -Throw '*AuthSessionOptions*hashtable*' } + + It 'acquires default auth session when AuthSessionName is absent but broker exists' { + # Arrange + $testState = [pscustomobject]@{ + SessionAcquired = $false + AcquiredName = $null + AcquiredOptions = $null + } + + $broker = [pscustomobject]@{ + PSTypeName = 'Tests.AuthSessionBroker' + State = $testState + } + $broker | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { + param($Name, $Options) + $this.State.SessionAcquired = $true + $this.State.AcquiredName = $Name + $this.State.AcquiredOptions = $Options + return [PSCredential]::new('defaultuser', (ConvertTo-SecureString 'defaultpass' -AsPlainText -Force)) + } -Force + + $mockProvider = [pscustomobject]@{ + PSTypeName = 'Tests.MockProvider' + } + $mockProvider | Add-Member -MemberType ScriptMethod -Name EnsureAttribute -Value { + param($IdentityKey, $Name, $Value, $AuthSession) + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Changed = $true + } + } -Force + + $context = [pscustomobject]@{ + PSTypeName = 'IdLE.ExecutionContext' + Providers = @{ + Identity = $mockProvider + AuthSessionBroker = $broker + } + } + $context | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { + param($Name, $Options) + return $this.Providers.AuthSessionBroker.AcquireAuthSession($Name, $Options) + } -Force + + $step = [pscustomobject]@{ + PSTypeName = 'IdLE.Step' + Name = 'TestStep' + Type = 'IdLE.Step.EnsureAttribute' + With = @{ + IdentityKey = 'testuser' + Name = 'Department' + Value = 'IT' + # No AuthSessionName - should still try to acquire default session + } + } + + # Act + $result = Invoke-IdleStepEnsureAttribute -Context $context -Step $step + + # Assert + $result | Should -Not -BeNullOrEmpty + $result.Status | Should -Be 'Completed' + $testState.SessionAcquired | Should -Be $true + $testState.AcquiredName | Should -Be '' + $testState.AcquiredOptions | Should -BeNullOrEmpty + } + + It 'throws when AuthSessionName is set but no broker is available' { + # Arrange + $mockProvider = [pscustomobject]@{ + PSTypeName = 'Tests.MockProvider' + } + $mockProvider | Add-Member -MemberType ScriptMethod -Name EnsureAttribute -Value { + param($IdentityKey, $Name, $Value, $AuthSession) + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Changed = $true + } + } -Force + + $context = [pscustomobject]@{ + PSTypeName = 'IdLE.ExecutionContext' + Providers = @{ + Identity = $mockProvider + # No AuthSessionBroker + } + } + + $step = [pscustomobject]@{ + PSTypeName = 'IdLE.Step' + Name = 'TestStep' + Type = 'IdLE.Step.EnsureAttribute' + With = @{ + IdentityKey = 'testuser' + Name = 'Department' + Value = 'IT' + AuthSessionName = 'ActiveDirectory' # Explicitly set but no broker + } + } + + # Act & Assert + { Invoke-IdleStepEnsureAttribute -Context $context -Step $step } | + Should -Throw '*AuthSessionName*AcquireAuthSession*' + } } }