Tests/EAI/EAIAccount.Tests.ps1
|
BeforeAll { if ($Module) { $script:DevModuleName = Import-Module $Module -Force -PassThru | Select-Object -ExpandProperty Name } else { $devModule = Join-Path $PSScriptRoot '..' '..' 'Dev.Logic.Monitor.psd1' $script:DevModuleName = Import-Module $devModule -Force -PassThru | Select-Object -ExpandProperty Name } } Describe 'EAI auth cmdlets' { BeforeEach { Disconnect-EAIAccount } It 'Sets EAI auth state from credentials' { Connect-EAIAccount -EdwinOrg 'myorg' -ClientId 'client' -ClientSecret 'secret' -SkipCredValidation $status = Get-EAIAccountStatus $status.Valid | Should -Be $true $status.EdwinOrg | Should -Be 'myorg' $status.PortalUrl | Should -Be 'https://myorg.dexda.ai' } It 'Reads auth values from YAML file' { $authFile = Join-Path $TestDrive 'edwin-auth.yaml' @' edwin_org: fileorg client_id: file-client client_secret: file-secret '@ | Set-Content -Path $authFile Connect-EAIAccount -AuthFilePath $authFile -SkipCredValidation $status = Get-EAIAccountStatus $status.EdwinOrg | Should -Be 'fileorg' $status.ClientId | Should -Be 'file-client' } It 'Returns account status when connected' { Connect-EAIAccount -EdwinOrg 'myorg' -ClientId 'client' -ClientSecret 'secret' -SkipCredValidation $status = Get-EAIAccountStatus $status.Valid | Should -Be $true $status.EdwinOrg | Should -Be 'myorg' } It 'Clears auth state on disconnect' { Connect-EAIAccount -EdwinOrg 'myorg' -ClientId 'client' -ClientSecret 'secret' -SkipCredValidation Disconnect-EAIAccount Get-EAIAccountStatus | Should -Be 'Not currently connected to any Edwin portals.' } It 'Stores console logging preference when DisableConsoleLogging is used' { Connect-EAIAccount -EdwinOrg 'myorg' -ClientId 'client' -ClientSecret 'secret' -SkipCredValidation -DisableConsoleLogging $status = Get-EAIAccountStatus $status.Logging | Should -Be $false InModuleScope -ModuleName $script:DevModuleName { $Script:InformationPreference | Should -Be 'SilentlyContinue' } } It 'Connects using a cached account by name' { Mock Get-SecretVault { } Mock Get-SecretInfo -ModuleName $script:DevModuleName { [PSCustomObject]@{ Name = 'EAI:myorg' Metadata = @{ Portal = 'myorg' Id = 'client-id' Type = 'EAI' } } } Mock Get-Secret -ModuleName $script:DevModuleName { 'client-secret' | ConvertTo-SecureString -AsPlainText -Force | ConvertFrom-SecureString } Connect-EAIAccount -CachedAccountName 'EAI:myorg' -SkipCredValidation $status = Get-EAIAccountStatus $status.EdwinOrg | Should -Be 'myorg' $status.ClientId | Should -Be 'client-id' InModuleScope -ModuleName $script:DevModuleName { $clientSecret = [System.Net.NetworkCredential]::new('', $Script:EAIAuth.ClientSecret).Password $clientSecret | Should -Be 'client-secret' } } It 'Connects using normalized selection numbers from -UseCachedCredential' { InModuleScope -ModuleName $script:DevModuleName { Mock Get-SecretVault { } Mock Get-SecretInfo { @( [PSCustomObject]@{ Name = 'EAI:first' Metadata = @{ Portal = 'first' Id = 'client-one' Type = 'EAI' } } [PSCustomObject]@{ Name = 'commercial-account' Metadata = @{ Portal = 'company' Id = 'lm-id' Type = 'LMv1' } } [PSCustomObject]@{ Name = 'EAI:second' Metadata = @{ Portal = 'second' Id = 'client-two' Type = 'EAI' } } ) } Mock Get-Secret { 'client-two-secret' | ConvertTo-SecureString -AsPlainText -Force | ConvertFrom-SecureString } Mock Read-Host { '1' } Connect-EAIAccount -UseCachedCredential -SkipCredValidation $status = Get-EAIAccountStatus $status.EdwinOrg | Should -Be 'second' $status.ClientId | Should -Be 'client-two' } } It 'Validates credentials via token grant when SkipCredValidation is not set' { InModuleScope -ModuleName $script:DevModuleName { Mock Invoke-RestMethod { return [PSCustomObject]@{ access_token = 'issued-token' token_type = 'Bearer' expires_in = 1800 } } { Connect-EAIAccount -EdwinOrg 'myorg' -ClientId 'client' -ClientSecret 'secret' } | Should -Not -Throw Should -Invoke Invoke-RestMethod -Times 1 -ParameterFilter { $Uri -eq 'https://myorg.dexda.ai/auth/token' -and $Method -eq 'POST' -and $ContentType -eq 'application/x-www-form-urlencoded' } $status = Get-EAIAccountStatus $status.TokenExpiresAt | Should -Not -BeNullOrEmpty } } It 'Fails connect when token grant returns HTTP 401' { InModuleScope -ModuleName $script:DevModuleName { Mock Invoke-RestMethod { $httpResponse = [System.Net.Http.HttpResponseMessage]::new([System.Net.HttpStatusCode]::Unauthorized) $httpResponse.Content = [System.Net.Http.StringContent]::new( '{"code":401,"message":"Credentials are required to access this resource."}', [System.Text.Encoding]::UTF8, 'application/json' ) $exception = [Microsoft.PowerShell.Commands.HttpResponseException]::new( 'Response status code does not indicate success: 401 (Unauthorized).', $httpResponse ) throw [System.Management.Automation.ErrorRecord]::new( $exception, 'InvokeRestMethod', [System.Management.Automation.ErrorCategory]::InvalidOperation, $null ) } $errorRecord = { Connect-EAIAccount -EdwinOrg 'myorg' -ClientId 'client' -ClientSecret 'secret' } | Should -Throw -ExpectedMessage '*Invalid Edwin credentials for portal*myorg*Verify EdwinOrg, ClientId, and ClientSecret*' -PassThru $errorRecord.FullyQualifiedErrorId | Should -Be 'EAI.AuthenticationError,Connect-EAIAccount' $errorRecord.CategoryInfo.Category | Should -Be 'AuthenticationError' } } It 'Includes trace id in invalid credential errors when Edwin returns one' { InModuleScope -ModuleName $script:DevModuleName { Mock Invoke-RestMethod { $httpResponse = [System.Net.Http.HttpResponseMessage]::new([System.Net.HttpStatusCode]::Unauthorized) $httpResponse.Content = [System.Net.Http.StringContent]::new( '{"code":401,"message":"Credentials are required to access this resource.","id":"7eec5062-abcd-efgh-ijkl-1234567890ab"}', [System.Text.Encoding]::UTF8, 'application/json' ) $exception = [Microsoft.PowerShell.Commands.HttpResponseException]::new( 'Response status code does not indicate success: 401 (Unauthorized).', $httpResponse ) throw [System.Management.Automation.ErrorRecord]::new( $exception, 'InvokeRestMethod', [System.Management.Automation.ErrorCategory]::InvalidOperation, $null ) } { Connect-EAIAccount -EdwinOrg 'myorg' -ClientId 'client' -ClientSecret 'secret' } | Should -Throw '*Invalid Edwin credentials for portal*trace id: 7eec5062-abcd-efgh-ijkl-1234567890ab*' } } It 'Skips token grant when SkipCredValidation is set' { InModuleScope -ModuleName $script:DevModuleName { Mock Invoke-RestMethod { throw 'Should not be called' } { Connect-EAIAccount -EdwinOrg 'myorg' -ClientId 'client' -ClientSecret 'secret' -SkipCredValidation } | Should -Not -Throw Should -Invoke Invoke-RestMethod -Times 0 -Exactly } } } Describe 'New-EAIHeader' { It 'Builds a Bearer auth header' { InModuleScope -ModuleName $script:DevModuleName { $auth = [PSCustomObject]@{ EdwinOrg = 'myorg' ClientId = 'user' ClientSecret = 'pass' | ConvertTo-SecureString -AsPlainText -Force AccessToken = 'bearer-token' | ConvertTo-SecureString -AsPlainText -Force TokenExpiresAt = (Get-Date).ToUniversalTime().AddHours(1) } $headers = New-EAIHeader -Auth $auth $headers.Authorization | Should -Be 'Bearer bearer-token' $headers.'Content-Type' | Should -Be 'application/json' $headers.'__EAIMethod' | Should -Be 'POST' } } } Describe 'Resolve-EAIDebugInfo' { It 'Strips __EAIMethod from headers before the HTTP request is sent' { InModuleScope -ModuleName $script:DevModuleName { Mock Invoke-RestMethod { return @([PSCustomObject]@{ eventId = '123'; acceptedStatus = $true }) } $auth = [PSCustomObject]@{ EdwinOrg = 'myorg' ClientId = 'user' ClientSecret = 'pass' | ConvertTo-SecureString -AsPlainText -Force AccessToken = 'bearer-token' | ConvertTo-SecureString -AsPlainText -Force TokenExpiresAt = (Get-Date).ToUniversalTime().AddHours(1) } $headers = New-EAIHeader -Auth $auth -Method 'POST' Invoke-EAIRestMethod -Uri 'https://myorg.dexda.ai/integration/event/v1' -Method POST -Headers $headers -Auth $auth -Body '[]' Should -Invoke Invoke-RestMethod -ParameterFilter { -not $Headers.ContainsKey('__EAIMethod') -and $Headers.Authorization -eq 'Bearer bearer-token' } } } } Describe 'Test-EAIEventPayload' { It 'Normalizes valid CEF payloads' { InModuleScope -ModuleName $script:DevModuleName { $event = @{ cef = @{ event_ci = 'ci' event_object = 'object' event_source = 'source' event_name = 'name' event_description = 'description' event_severity = 'major' event_time = '2024-01-01T00:00:00.000Z' event_id = [guid]::NewGuid().ToString() event_domain = '' source_record = @{ id = 1 } } enrichments = @{} } $normalized = Test-EAIEventPayload -Event $event $normalized.cef.event_severity | Should -Be 4 $normalized.cef.class | Should -Be 'event' $normalized.cef.version | Should -Be '1.1' $normalized.cef.event_time | Should -Match '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{5}Z$' } } It 'Normalizes event_time to Edwin five-digit fractional format' { InModuleScope -ModuleName $script:DevModuleName { $event = @{ cef = @{ event_ci = 'ci' event_object = 'object' event_source = 'source' event_name = 'name' event_description = 'description' event_severity = 'major' event_time = '2026-07-20T19:40:55.107Z' event_id = [guid]::NewGuid().ToString() event_domain = '' source_record = @{ id = 1 } } enrichments = @{} } $normalized = Test-EAIEventPayload -Event $event $normalized.cef.event_time | Should -Be '2026-07-20T19:40:55.10700Z' } } It 'Throws when required CEF fields are missing' { InModuleScope -ModuleName $script:DevModuleName { { Test-EAIEventPayload -Event @{ cef = @{ event_name = 'only-name' }; enrichments = @{} } } | Should -Throw "CEF payload is missing required field 'event_ci'." } } } Describe 'Send-EAIEvents' { BeforeEach { Disconnect-EAIAccount Connect-EAIAccount -EdwinOrg 'myorg' -ClientId 'client' -ClientSecret 'secret' -SkipCredValidation InModuleScope -ModuleName $script:DevModuleName { $Script:EAIAuth.AccessToken = 'test-token' | ConvertTo-SecureString -AsPlainText -Force $Script:EAIAuth.TokenExpiresAt = (Get-Date).ToUniversalTime().AddHours(1) } } It 'Requires an active Edwin connection' { Disconnect-EAIAccount { Send-EAIEvents -Events @{ cef = @{}; enrichments = @{} } -ErrorAction Stop } | Should -Throw } It 'Posts normalized events to Edwin' { InModuleScope -ModuleName $script:DevModuleName { Mock Invoke-EAIRestMethod { return @(@{ eventId = '123'; acceptedStatus = $true }) } $event = New-EAIEvent -EventCi 'ci' -EventObject 'obj' -EventSource 'vendor' -EventName 'alert' -EventDescription 'details' -Severity Major -SourceRecord @{ id = 1 } $result = Send-EAIEvents -Events $event -PassThru -Confirm:$false Should -Invoke Invoke-EAIRestMethod -Times 1 -ParameterFilter { $Uri -eq 'https://myorg.dexda.ai/integration/event/v1' -and $Method -eq 'POST' -and $Body -match '"event_severity":\s*4' } $result.acceptedStatus | Should -Be $true } } } Describe 'Invoke-EAIRestMethod' { It 'Returns parsed JSON for successful responses' { InModuleScope -ModuleName $script:DevModuleName { Mock Invoke-RestMethod { return @([PSCustomObject]@{ eventId = '123'; acceptedStatus = $true }) } $auth = [PSCustomObject]@{ EdwinOrg = 'myorg' ClientId = 'user' ClientSecret = 'pass' | ConvertTo-SecureString -AsPlainText -Force AccessToken = 'bearer-token' | ConvertTo-SecureString -AsPlainText -Force TokenExpiresAt = (Get-Date).ToUniversalTime().AddHours(1) } $result = Invoke-EAIRestMethod -Uri 'https://myorg.dexda.ai/integration/event/v1' -Method POST -Headers @{ Authorization = 'Bearer bearer-token' } -Auth $auth $result[0].acceptedStatus | Should -Be $true } } It 'Formats Edwin API error responses' { InModuleScope -ModuleName $script:DevModuleName { $message = Format-EAIErrorMessage -ResponseBody '{"code":401,"message":"Credentials are required to access this resource."}' -StatusCode 401 $message | Should -Be '401: Credentials are required to access this resource.' } } It 'Throws a formatted authentication error for HTTP 401' { InModuleScope -ModuleName $script:DevModuleName { Mock Request-EAIAccessToken { return [PSCustomObject]@{ AccessToken = ('refreshed-token' | ConvertTo-SecureString -AsPlainText -Force) ExpiresAt = (Get-Date).ToUniversalTime().AddHours(1) } } Mock Invoke-RestMethod { $httpResponse = [System.Net.Http.HttpResponseMessage]::new([System.Net.HttpStatusCode]::Unauthorized) $httpResponse.Content = [System.Net.Http.StringContent]::new( '{"code":401,"message":"Credentials are required to access this resource."}', [System.Text.Encoding]::UTF8, 'application/json' ) $exception = [Microsoft.PowerShell.Commands.HttpResponseException]::new( 'Response status code does not indicate success: 401 (Unauthorized).', $httpResponse ) throw [System.Management.Automation.ErrorRecord]::new( $exception, 'InvokeRestMethod', [System.Management.Automation.ErrorCategory]::InvalidOperation, $null ) } $auth = [PSCustomObject]@{ EdwinOrg = 'myorg' ClientId = 'user' ClientSecret = 'pass' | ConvertTo-SecureString -AsPlainText -Force AccessToken = 'bearer-token' | ConvertTo-SecureString -AsPlainText -Force TokenExpiresAt = (Get-Date).ToUniversalTime().AddHours(1) } { Invoke-EAIRestMethod -Uri 'https://myorg.dexda.ai/integration/event/v1' -Method POST -Headers @{ Authorization = 'Bearer bearer-token' } -Auth $auth } | Should -Throw '*401: Credentials are required to access this resource.*Connect-EAIAccount*' Should -Invoke Request-EAIAccessToken -Times 1 -Exactly } } It 'Refreshes the token and retries once after HTTP 401' { InModuleScope -ModuleName $script:DevModuleName { $script:invokeCount = 0 Mock Request-EAIAccessToken { return [PSCustomObject]@{ AccessToken = ('refreshed-token' | ConvertTo-SecureString -AsPlainText -Force) ExpiresAt = (Get-Date).ToUniversalTime().AddHours(1) } } Mock Invoke-RestMethod { $script:invokeCount++ if ($script:invokeCount -eq 1) { $httpResponse = [System.Net.Http.HttpResponseMessage]::new([System.Net.HttpStatusCode]::Unauthorized) $httpResponse.Content = [System.Net.Http.StringContent]::new( '{"code":401,"message":"Credentials are required to access this resource."}', [System.Text.Encoding]::UTF8, 'application/json' ) $exception = [Microsoft.PowerShell.Commands.HttpResponseException]::new( 'Response status code does not indicate success: 401 (Unauthorized).', $httpResponse ) throw [System.Management.Automation.ErrorRecord]::new( $exception, 'InvokeRestMethod', [System.Management.Automation.ErrorCategory]::InvalidOperation, $null ) } return @([PSCustomObject]@{ eventId = '123'; acceptedStatus = $true }) } $auth = [PSCustomObject]@{ EdwinOrg = 'myorg' ClientId = 'user' ClientSecret = 'pass' | ConvertTo-SecureString -AsPlainText -Force AccessToken = 'bearer-token' | ConvertTo-SecureString -AsPlainText -Force TokenExpiresAt = (Get-Date).ToUniversalTime().AddHours(1) } $result = Invoke-EAIRestMethod -Uri 'https://myorg.dexda.ai/integration/event/v1' -Method POST -Headers @{ Authorization = 'Bearer bearer-token' } -Auth $auth $result[0].acceptedStatus | Should -Be $true $script:invokeCount | Should -Be 2 Should -Invoke Request-EAIAccessToken -Times 1 -Exactly } } It 'Reads error bodies from ErrorDetails on PowerShell 7' { InModuleScope -ModuleName $script:DevModuleName { $errorRecord = [System.Management.Automation.ErrorRecord]::new( [System.InvalidOperationException]::new('Request failed'), 'InvokeWebRequest', [System.Management.Automation.ErrorCategory]::InvalidOperation, $null ) $errorRecord.ErrorDetails = [System.Management.Automation.ErrorDetails]::new('{"message":"invalid payload"}') $details = Get-EAIHttpErrorDetails -ErrorRecord $errorRecord $details.Body | Should -Be '{"message":"invalid payload"}' } } } Describe 'New-EAIEvent and Convert-EAIEvent' { It 'Builds a CEF object from parameters' { $event = New-EAIEvent -EventCi 'host' -EventObject 'cpu' -EventSource 'VendorX' -EventName 'High CPU' -EventDescription 'threshold exceeded' -Severity Critical -SourceRecord @{ host = 'host' } $event.cef.event_severity | Should -Be 5 $event.cef.class | Should -Be 'event' $event.PSObject.Properties.Name | Should -Contain 'enrichments' } It 'Maps third-party objects using PropertyMap' { $source = [PSCustomObject]@{ HostName = 'server01' AlertTitle = 'Disk full' Details = 'Disk usage exceeded 90%' Component = 'disk' Severity = 'maj' } $event = $source | Convert-EAIEvent -PropertyMap @{ event_ci = 'HostName' event_name = 'AlertTitle' event_description = 'Details' event_object = 'Component' event_severity = 'Severity' event_source = { 'VendorX' } } -SeverityMap @{ major = @('maj') } $event.cef.event_ci | Should -Be 'server01' $event.cef.event_severity | Should -Be 4 $event.cef.event_source | Should -Be 'VendorX' } } Describe 'EAICachedAccount cmdlets' { It 'Filters Edwin cached accounts by metadata type' { Mock Get-SecretInfo -ModuleName $script:DevModuleName { @( [PSCustomObject]@{ Name = 'EAI:myorg' Metadata = @{ Portal = 'myorg' Id = 'client-id' Modified = Get-Date Type = 'EAI' } } [PSCustomObject]@{ Name = 'commercial-account' Metadata = @{ Portal = 'company' Id = 'lm-id' Modified = Get-Date Type = 'LMv1' } } ) } $accounts = Get-EAICachedAccount $accounts.Count | Should -Be 1 $accounts[0].CachedAccountName | Should -Be 'EAI:myorg' $accounts[0].EdwinOrg | Should -Be 'myorg' } } |