Testing/Unit/PowerShell/Providers/PowerBIProvider/Export-PowerBIProvider.Tests.ps1
|
<#
# # Due to how the Error handling was implemented, mocked API calls have to be mocked inside a # mocked CommandTracker class. # # The Power BI Admin REST API commonly fails with 403/401 when nobody has logged into # the Power BI portal, when the interactive user lacks the Fabric Administrator role, # or when the service principal security group tenant setting is not configured. # These tests lock down the custom guidance emitted for those conditions, and make sure # unrelated failures do NOT emit that guidance. #> $ProviderPath = "../../../../../Modules/Providers" Import-Module (Join-Path -Path $PSScriptRoot -ChildPath "$($ProviderPath)/ExportPowerBIProvider.psm1") -Function Export-PowerBIProvider -Force Import-Module (Join-Path -Path $PSScriptRoot -ChildPath "$($ProviderPath)/ProviderHelpers/CommandTracker.psm1") -Force InModuleScope -ModuleName ExportPowerBIProvider { Describe -Tag 'PowerBIProvider' -Name "Export-PowerBIProvider" { BeforeAll { class MockCommandTracker { # The provider throws on unexpected failures, so its tracker instance is discarded before the # caller can inspect it. These static logs capture what was recorded anyway. static [System.Collections.Generic.List[string]]$Successful = [System.Collections.Generic.List[string]]::new() static [System.Collections.Generic.List[string]]$UnSuccessful = [System.Collections.Generic.List[string]]::new() static [void] Reset() { [MockCommandTracker]::Successful.Clear() [MockCommandTracker]::UnSuccessful.Clear() } [string[]]$SuccessfulCommands = @() [string[]]$UnSuccessfulCommands = @() [System.Object[]] TryCommand([string]$Command, [hashtable]$CommandArgs) { throw "ERROR the Power BI provider should not route calls through TryCommand: $($Command)" } [System.Object[]] TryCommand([string]$Command) { return $this.TryCommand($Command, @{}) } [void] AddSuccessfulCommand([string]$Command) { $this.SuccessfulCommands += $Command [MockCommandTracker]::Successful.Add($Command) } [void] AddUnSuccessfulCommand([string]$Command) { $this.UnSuccessfulCommands += $Command [MockCommandTracker]::UnSuccessful.Add($Command) } [string[]] GetUnSuccessfulCommands() { return $this.UnSuccessfulCommands } [string[]] GetSuccessfulCommands() { return $this.SuccessfulCommands } } function Get-CommandTracker {} Mock -ModuleName ExportPowerBIProvider Get-CommandTracker { return [MockCommandTracker]::New() } # The provider emits a bare JSON fragment, so wrap it in braces before parsing. function ConvertFrom-ProviderJson { param ( [string] $Json ) ConvertFrom-Json "{$($Json.TrimEnd(','))}" -ErrorAction Stop } # Real tenant settings shape: an object carrying a tenantSettings array. function New-MockAdminSettings { [pscustomobject]@{ tenantSettings = @( [pscustomobject]@{ settingName = "PublishToWeb" title = "Publish to web" enabled = $false }, [pscustomobject]@{ settingName = "ServicePrincipalAccessPermissionAPIs" enabled = $true enabledSecurityGroups = @( [pscustomobject]@{ graphId = "00000000-0000-0000-0000-000000000000" name = "PBI SP Group" } ) } ) } } # Mimics what Get-PowerBITenantSettingsRest (via Invoke-ScubaRestMethod) rethrows for an HTTP failure. function New-RestError { param ([string]$Message) [System.Net.WebException]::new($Message) } # Mimics an HTTP failure that carries a response, where the provider reads the status code # instead of the message text. class MockHttpException : System.Exception { [object]$Response MockHttpException([string]$Message, [int]$StatusCode) : base($Message) { $this.Response = [pscustomobject]@{ StatusCode = $StatusCode } } } function New-RestErrorWithStatus { param ([string]$Message, [int]$StatusCode) [MockHttpException]::new($Message, $StatusCode) } } Context 'When the tenant has no Power BI license' { BeforeEach { Mock -ModuleName ExportPowerBIProvider Get-PowerBITenantSettingsRest { throw "should not be called" } } It 'returns valid JSON without calling the Power BI API' { $Json = Export-PowerBIProvider -LicenseFound $false | Select-Object -Last 1 { ConvertFrom-ProviderJson -Json $Json } | Should -Not -Throw Should -Invoke -ModuleName ExportPowerBIProvider -CommandName Get-PowerBITenantSettingsRest -Times 0 -Exactly } It 'reports powerbi_license_found as false with empty tenant settings' { $Json = Export-PowerBIProvider -LicenseFound $false | Select-Object -Last 1 $Parsed = ConvertFrom-ProviderJson -Json $Json $Parsed.powerbi_license_found | Should -Be $false @($Parsed.powerbi_tenant_settings).Count | Should -Be 0 } It 'does not require AccessToken or BaseUrl' { { Export-PowerBIProvider -LicenseFound $false } | Should -Not -Throw } It 'does not emit the permissions guidance banner' { Mock -ModuleName ExportPowerBIProvider Write-Information {} Export-PowerBIProvider -LicenseFound $false | Out-Null Should -Invoke -ModuleName ExportPowerBIProvider -CommandName Write-Information -Times 0 -Exactly } } # The Rego composes its report details from powerbi_license_reason, so the reason has to # survive the trip from Connect-Tenant through this provider and into the JSON verbatim. Context 'When reporting why the license check failed' { BeforeEach { Mock -ModuleName ExportPowerBIProvider Get-PowerBITenantSettingsRest { throw "should not be called" } } It 'emits the tenant-level reason' { $Reason = 'No Power BI or Fabric license found in the tenant.' $Json = Export-PowerBIProvider -LicenseFound $false -LicenseReason $Reason | Select-Object -Last 1 (ConvertFrom-ProviderJson -Json $Json).powerbi_license_reason | Should -Be $Reason } It 'emits the per-user reason' { $Reason = 'Current user does not have a Power BI or Fabric license assigned. Assign a license (e.g., Microsoft Fabric (Free), Power BI Pro) to the running user.' $Json = Export-PowerBIProvider -LicenseFound $false -LicenseReason $Reason | Select-Object -Last 1 (ConvertFrom-ProviderJson -Json $Json).powerbi_license_reason | Should -Be $Reason } # Parentheses and commas in the per-user reason must not break the JSON fragment. It 'produces valid JSON for a reason containing punctuation' { $Reason = 'Assign a license (e.g., Microsoft Fabric (Free), Power BI Pro) to the "running" user.' $Json = Export-PowerBIProvider -LicenseFound $false -LicenseReason $Reason | Select-Object -Last 1 { ConvertFrom-ProviderJson -Json $Json } | Should -Not -Throw (ConvertFrom-ProviderJson -Json $Json).powerbi_license_reason | Should -Be $Reason } # Empty is what makes the Rego fall back to its original generic message. It 'emits an empty reason when none is supplied' { $Json = Export-PowerBIProvider -LicenseFound $false | Select-Object -Last 1 (ConvertFrom-ProviderJson -Json $Json).powerbi_license_reason | Should -Be '' } It 'emits an empty reason on the licensed path' { Mock -ModuleName ExportPowerBIProvider Get-PowerBITenantSettingsRest { New-MockAdminSettings } $Json = Export-PowerBIProvider -LicenseFound $true -AccessToken 'mock-token' -BaseUrl 'https://api.powerbi.com' | Select-Object -Last 1 (ConvertFrom-ProviderJson -Json $Json).powerbi_license_reason | Should -Be '' (ConvertFrom-ProviderJson -Json $Json).powerbi_access_denied_reason | Should -Be '' } } Context 'When LicenseFound is true but credentials are incomplete' { BeforeEach { Mock -ModuleName ExportPowerBIProvider Get-PowerBITenantSettingsRest { throw "should not be called" } } It 'throws when AccessToken is missing' { { Export-PowerBIProvider -LicenseFound $true -BaseUrl 'https://api.powerbi.com' } | Should -Throw '*AccessToken and BaseUrl must be provided*' } It 'throws when BaseUrl is missing' { { Export-PowerBIProvider -LicenseFound $true -AccessToken 'mock-token' } | Should -Throw '*AccessToken and BaseUrl must be provided*' } It 'throws when AccessToken is an empty string' { { Export-PowerBIProvider -LicenseFound $true -AccessToken '' -BaseUrl 'https://api.powerbi.com' } | Should -Throw '*AccessToken and BaseUrl must be provided*' } It 'throws when BaseUrl is an empty string' { { Export-PowerBIProvider -LicenseFound $true -AccessToken 'mock-token' -BaseUrl '' } | Should -Throw '*AccessToken and BaseUrl must be provided*' } It 'does not call the Power BI API when credentials are incomplete' { { Export-PowerBIProvider -LicenseFound $true -AccessToken 'mock-token' } | Should -Throw Should -Invoke -ModuleName ExportPowerBIProvider -CommandName Get-PowerBITenantSettingsRest -Times 0 -Exactly } } Context 'When the Power BI API returns tenant settings' { BeforeEach { Mock -ModuleName ExportPowerBIProvider Get-PowerBITenantSettingsRest { New-MockAdminSettings } Mock -ModuleName ExportPowerBIProvider Write-Information {} } It 'returns valid JSON' { $Json = Export-PowerBIProvider -LicenseFound $true -AccessToken 'mock-token' -BaseUrl 'https://api.powerbi.com' | Select-Object -Last 1 { ConvertFrom-ProviderJson -Json $Json } | Should -Not -Throw } It 'projects tenantSettings into powerbi_tenant_settings keyed by settingName' { $Json = Export-PowerBIProvider -LicenseFound $true -AccessToken 'mock-token' -BaseUrl 'https://api.powerbi.com' | Select-Object -Last 1 $Settings = @((ConvertFrom-ProviderJson -Json $Json).powerbi_tenant_settings) $Settings.Count | Should -Be 2 $Settings.settingName | Should -Contain 'PublishToWeb' $Settings.settingName | Should -Contain 'ServicePrincipalAccessPermissionAPIs' } It 'preserves nested security group details that the Rego needs' { $Json = Export-PowerBIProvider -LicenseFound $true -AccessToken 'mock-token' -BaseUrl 'https://api.powerbi.com' | Select-Object -Last 1 $Settings = @((ConvertFrom-ProviderJson -Json $Json).powerbi_tenant_settings) $SpSetting = $Settings | Where-Object { $_.settingName -eq 'ServicePrincipalAccessPermissionAPIs' } @($SpSetting.enabledSecurityGroups).Count | Should -Be 1 $SpSetting.enabledSecurityGroups[0].name | Should -Be 'PBI SP Group' } It 'records Get-PowerBITenantSettingsRest as a successful command so the Rego resolves it' { $Json = Export-PowerBIProvider -LicenseFound $true -AccessToken 'mock-token' -BaseUrl 'https://api.powerbi.com' | Select-Object -Last 1 $Parsed = ConvertFrom-ProviderJson -Json $Json @($Parsed.powerbi_successful_commands) | Should -Contain 'Get-PowerBITenantSettingsRest' @($Parsed.powerbi_unsuccessful_commands).Count | Should -Be 0 } It 'reports powerbi_license_found as true' { $Json = Export-PowerBIProvider -LicenseFound $true -AccessToken 'mock-token' -BaseUrl 'https://api.powerbi.com' | Select-Object -Last 1 (ConvertFrom-ProviderJson -Json $Json).powerbi_license_found | Should -Be $true } It 'calls Get-PowerBITenantSettingsRest with the supplied token and base URL' { Export-PowerBIProvider -LicenseFound $true -AccessToken 'mock-token' -BaseUrl 'https://api.high.powerbigov.us' | Out-Null Should -Invoke -ModuleName ExportPowerBIProvider -CommandName Get-PowerBITenantSettingsRest -Times 1 -Exactly -ParameterFilter { $BaseUrl -eq 'https://api.high.powerbigov.us' -and $AccessToken -eq 'mock-token' } } It 'does not emit the permissions guidance banner on success' { Export-PowerBIProvider -LicenseFound $true -AccessToken 'mock-token' -BaseUrl 'https://api.powerbi.com' | Out-Null Should -Invoke -ModuleName ExportPowerBIProvider -CommandName Write-Information -Times 0 -Exactly } } Context 'When the Power BI API returns an empty response' { BeforeEach { Mock -ModuleName ExportPowerBIProvider Write-Information {} } It 'throws an actionable error when an empty array is returned' { Mock -ModuleName ExportPowerBIProvider Get-PowerBITenantSettingsRest { @() } { Export-PowerBIProvider -LicenseFound $true -AccessToken 'mock-token' -BaseUrl 'https://api.powerbi.com' } | Should -Throw '*No tenant settings were returned*' } It 'throws an actionable error when null is returned' { Mock -ModuleName ExportPowerBIProvider Get-PowerBITenantSettingsRest { $null } { Export-PowerBIProvider -LicenseFound $true -AccessToken 'mock-token' -BaseUrl 'https://api.powerbi.com' } | Should -Throw '*No tenant settings were returned*' } It 'does not emit the permissions guidance banner for an empty response' { Mock -ModuleName ExportPowerBIProvider Get-PowerBITenantSettingsRest { @() } { Export-PowerBIProvider -LicenseFound $true -AccessToken 'mock-token' -BaseUrl 'https://api.powerbi.com' } | Should -Throw Should -Invoke -ModuleName ExportPowerBIProvider -CommandName Write-Information -Times 0 -Exactly } It 'still returns valid JSON when the response lacks a tenantSettings property' { Mock -ModuleName ExportPowerBIProvider Get-PowerBITenantSettingsRest { [pscustomobject]@{ unexpected = 'shape' } } $Json = Export-PowerBIProvider -LicenseFound $true -AccessToken 'mock-token' -BaseUrl 'https://api.powerbi.com' | Select-Object -Last 1 { ConvertFrom-ProviderJson -Json $Json } | Should -Not -Throw } } Context 'When the Power BI API returns 403 Forbidden under interactive auth' { BeforeEach { Mock -ModuleName ExportPowerBIProvider Get-PowerBITenantSettingsRest { throw (New-RestError -Message "The remote server returned an error: (403) Forbidden.") } Mock -ModuleName ExportPowerBIProvider Write-Information {} } It 'returns the interactive fix in powerbi_access_denied_reason' { $Json = Export-PowerBIProvider -LicenseFound $true -AccessToken 'mock-token' -BaseUrl 'https://api.powerbi.com' | Select-Object -Last 1 $Result = ConvertFrom-ProviderJson -Json $Json $Result.powerbi_access_denied_reason | Should -Match '403 Forbidden' $Result.powerbi_access_denied_reason | Should -Match 'Fabric Administrator role' @($Result.powerbi_tenant_settings).Count | Should -Be 0 $Result.powerbi_successful_commands | Should -Not -Contain 'Get-PowerBITenantSettingsRest' } It 'tells the user they need the Fabric Administrator role' { Export-PowerBIProvider -LicenseFound $true -AccessToken 'mock-token' -BaseUrl 'https://api.powerbi.com' | Out-Null Should -Invoke -ModuleName ExportPowerBIProvider -CommandName Write-Information -ParameterFilter { $MessageData -match 'Fabric Administrator role' } } It 'tells the user to sign into the Power BI portal at least once' { Export-PowerBIProvider -LicenseFound $true -AccessToken 'mock-token' -BaseUrl 'https://api.powerbi.com' | Out-Null Should -Invoke -ModuleName ExportPowerBIProvider -CommandName Write-Information -ParameterFilter { $MessageData -match 'sign into the Power BI portal at least once' } } It 'does not show the service principal security group guidance' { Export-PowerBIProvider -LicenseFound $true -AccessToken 'mock-token' -BaseUrl 'https://api.powerbi.com' | Out-Null Should -Invoke -ModuleName ExportPowerBIProvider -CommandName Write-Information -Times 0 -Exactly -ParameterFilter { $MessageData -match 'security group for the service principal' } } } Context 'When the Power BI API returns 403 Forbidden under service principal auth' { BeforeEach { Mock -ModuleName ExportPowerBIProvider Get-PowerBITenantSettingsRest { throw (New-RestError -Message "The remote server returned an error: (403) Forbidden.") } Mock -ModuleName ExportPowerBIProvider Write-Information {} } It 'returns the service principal fix in powerbi_access_denied_reason' { $Json = Export-PowerBIProvider -CertificateBasedAuth -LicenseFound $true -AccessToken 'mock-token' -BaseUrl 'https://api.powerbi.com' | Select-Object -Last 1 $Result = ConvertFrom-ProviderJson -Json $Json $Result.powerbi_access_denied_reason | Should -Match 'security group for the service principal' $Result.powerbi_access_denied_reason | Should -Match 'noninteractive\.md#power-bi-tenant-setting' $Result.powerbi_access_denied_reason | Should -Match 'Tenant.Read.All' } It 'tells the user to configure a security group for the service principal' { Export-PowerBIProvider -CertificateBasedAuth -LicenseFound $true -AccessToken 'mock-token' -BaseUrl 'https://api.powerbi.com' | Out-Null Should -Invoke -ModuleName ExportPowerBIProvider -CommandName Write-Information -ParameterFilter { $MessageData -match 'security group for the service principal' } } It 'links to the noninteractive Power BI tenant setting documentation' { Export-PowerBIProvider -CertificateBasedAuth -LicenseFound $true -AccessToken 'mock-token' -BaseUrl 'https://api.powerbi.com' | Out-Null Should -Invoke -ModuleName ExportPowerBIProvider -CommandName Write-Information -ParameterFilter { $MessageData -match 'noninteractive\.md#power-bi-tenant-setting' } } It 'does not show the interactive Fabric Administrator guidance' { Export-PowerBIProvider -CertificateBasedAuth -LicenseFound $true -AccessToken 'mock-token' -BaseUrl 'https://api.powerbi.com' | Out-Null Should -Invoke -ModuleName ExportPowerBIProvider -CommandName Write-Information -Times 0 -Exactly -ParameterFilter { $MessageData -match 'Fabric Administrator role' } } It 'treats -CertificateBasedAuth:$false as interactive auth' { Export-PowerBIProvider -CertificateBasedAuth:$false -LicenseFound $true -AccessToken 'mock-token' -BaseUrl 'https://api.powerbi.com' | Out-Null Should -Invoke -ModuleName ExportPowerBIProvider -CommandName Write-Information -ParameterFilter { $MessageData -match 'Fabric Administrator role' } } } Context 'When the Power BI API returns 401 Unauthorized' { BeforeEach { Mock -ModuleName ExportPowerBIProvider Get-PowerBITenantSettingsRest { throw (New-RestError -Message "The remote server returned an error: (401) Unauthorized.") } Mock -ModuleName ExportPowerBIProvider Write-Information {} } It 'reports 401 Unauthorized in powerbi_access_denied_reason' { $Json = Export-PowerBIProvider -CertificateBasedAuth -LicenseFound $true -AccessToken 'mock-token' -BaseUrl 'https://api.powerbi.com' | Select-Object -Last 1 (ConvertFrom-ProviderJson -Json $Json).powerbi_access_denied_reason | Should -Match '401 Unauthorized' } It 'shows the service principal guidance under service principal auth' { Export-PowerBIProvider -CertificateBasedAuth -LicenseFound $true -AccessToken 'mock-token' -BaseUrl 'https://api.powerbi.com' | Out-Null Should -Invoke -ModuleName ExportPowerBIProvider -CommandName Write-Information -ParameterFilter { $MessageData -match 'security group for the service principal' } } It 'shows the interactive guidance under interactive auth' { Export-PowerBIProvider -LicenseFound $true -AccessToken 'mock-token' -BaseUrl 'https://api.powerbi.com' | Out-Null Should -Invoke -ModuleName ExportPowerBIProvider -CommandName Write-Information -ParameterFilter { $MessageData -match 'Fabric Administrator role' } } } # When there's a response, its status code decides rather than the message text Context 'When the failure carries an HTTP response' { BeforeEach { Mock -ModuleName ExportPowerBIProvider Write-Information {} Mock -ModuleName ExportPowerBIProvider Write-Warning {} } # PS 7 formats this as "403 (Forbidden)", so the (403) message fallback would not match it It 'shows the fix for a 403 using the status code' { Mock -ModuleName ExportPowerBIProvider Get-PowerBITenantSettingsRest { throw (New-RestErrorWithStatus -Message "Response status code does not indicate success: 403 (Forbidden)." -StatusCode 403) } $Json = Export-PowerBIProvider -LicenseFound $true -AccessToken 'mock-token' -BaseUrl 'https://api.powerbi.com' | Select-Object -Last 1 (ConvertFrom-ProviderJson -Json $Json).powerbi_access_denied_reason | Should -Match '403 Forbidden.*Fabric Administrator role' } It 'does not show guidance for a 500 whose message contains (403)' { Mock -ModuleName ExportPowerBIProvider Get-PowerBITenantSettingsRest { throw (New-RestErrorWithStatus -Message "Upstream call failed with (403) while proxying" -StatusCode 500) } $Json = Export-PowerBIProvider -LicenseFound $true -AccessToken 'mock-token' -BaseUrl 'https://api.powerbi.com' | Select-Object -Last 1 (ConvertFrom-ProviderJson -Json $Json).powerbi_access_denied_reason | Should -Be '' Should -Invoke -ModuleName ExportPowerBIProvider -CommandName Write-Information -Times 0 -Exactly } } Context 'When the Power BI API fails for a reason unrelated to permissions' { BeforeEach { Mock -ModuleName ExportPowerBIProvider Write-Information {} Mock -ModuleName ExportPowerBIProvider Write-Warning {} } # Matches what TryCommand did before, so the report shows these policies as errors # instead of leaving Power BI out It 'still returns JSON with Get-PowerBITenantSettingsRest unsuccessful and no access denied reason' { Mock -ModuleName ExportPowerBIProvider Get-PowerBITenantSettingsRest { throw (New-RestError -Message "The remote server returned an error: (429) Too Many Requests.") } $Json = Export-PowerBIProvider -LicenseFound $true -AccessToken 'mock-token' -BaseUrl 'https://api.powerbi.com' | Select-Object -Last 1 $Result = ConvertFrom-ProviderJson -Json $Json $Result.powerbi_unsuccessful_commands | Should -Contain 'Get-PowerBITenantSettingsRest' $Result.powerbi_successful_commands | Should -Not -Contain 'Get-PowerBITenantSettingsRest' $Result.powerbi_access_denied_reason | Should -Be '' Should -Invoke -ModuleName ExportPowerBIProvider -CommandName Write-Warning -Times 1 -Exactly } # Guards against the guidance banner firing on any failure, which would send # users chasing permissions problems they do not have. It 'does not emit guidance for a 500 Internal Server Error' { Mock -ModuleName ExportPowerBIProvider Get-PowerBITenantSettingsRest { throw (New-RestError -Message "The remote server returned an error: (500) Internal Server Error.") } { Export-PowerBIProvider -LicenseFound $true -AccessToken 'mock-token' -BaseUrl 'https://api.powerbi.com' | Out-Null } | Should -Not -Throw Should -Invoke -ModuleName ExportPowerBIProvider -CommandName Write-Information -Times 0 -Exactly } It 'does not emit guidance for a 429 Too Many Requests' { Mock -ModuleName ExportPowerBIProvider Get-PowerBITenantSettingsRest { throw (New-RestError -Message "The remote server returned an error: (429) Too Many Requests.") } { Export-PowerBIProvider -LicenseFound $true -AccessToken 'mock-token' -BaseUrl 'https://api.powerbi.com' | Out-Null } | Should -Not -Throw Should -Invoke -ModuleName ExportPowerBIProvider -CommandName Write-Information -Times 0 -Exactly } It 'does not emit guidance for a 404 Not Found' { Mock -ModuleName ExportPowerBIProvider Get-PowerBITenantSettingsRest { throw (New-RestError -Message "The remote server returned an error: (404) Not Found.") } { Export-PowerBIProvider -LicenseFound $true -AccessToken 'mock-token' -BaseUrl 'https://api.powerbi.com' | Out-Null } | Should -Not -Throw Should -Invoke -ModuleName ExportPowerBIProvider -CommandName Write-Information -Times 0 -Exactly } It 'does not emit guidance for a network failure with no status code' { Mock -ModuleName ExportPowerBIProvider Get-PowerBITenantSettingsRest { throw (New-RestError -Message "Unable to connect to the remote server") } { Export-PowerBIProvider -LicenseFound $true -AccessToken 'mock-token' -BaseUrl 'https://api.powerbi.com' | Out-Null } | Should -Not -Throw Should -Invoke -ModuleName ExportPowerBIProvider -CommandName Write-Information -Times 0 -Exactly } # When the exception has no HTTP response, the provider falls back to looking for "(401)" or # "(403)" in the error message. These messages mention 403 or Forbidden without that exact # form, so they must not be mistaken for a permissions error. It 'does not emit guidance when a message contains 403 outside of parentheses' { Mock -ModuleName ExportPowerBIProvider Get-PowerBITenantSettingsRest { throw (New-RestError -Message "Correlation id 4030f1ac-0000-0000-0000-000000000000 request failed") } { Export-PowerBIProvider -LicenseFound $true -AccessToken 'mock-token' -BaseUrl 'https://api.powerbi.com' | Out-Null } | Should -Not -Throw Should -Invoke -ModuleName ExportPowerBIProvider -CommandName Write-Information -Times 0 -Exactly } It 'does not emit guidance when a message contains Forbidden but no status code' { Mock -ModuleName ExportPowerBIProvider Get-PowerBITenantSettingsRest { throw (New-RestError -Message "Operation Forbidden by tenant policy") } { Export-PowerBIProvider -LicenseFound $true -AccessToken 'mock-token' -BaseUrl 'https://api.powerbi.com' | Out-Null } | Should -Not -Throw Should -Invoke -ModuleName ExportPowerBIProvider -CommandName Write-Information -Times 0 -Exactly } } Context 'Debug logging of the permissions guidance' { BeforeEach { Mock -ModuleName ExportPowerBIProvider Write-Information {} Mock -ModuleName ExportPowerBIProvider Write-Warning {} Mock -ModuleName ExportPowerBIProvider Write-ScubaLog {} } It 'logs the interactive guidance on a 403' { Mock -ModuleName ExportPowerBIProvider Get-PowerBITenantSettingsRest { throw (New-RestError -Message "The remote server returned an error: (403) Forbidden.") } Export-PowerBIProvider -LicenseFound $true -AccessToken 'mock-token' -BaseUrl 'https://api.powerbi.com' | Out-Null Should -Invoke -ModuleName ExportPowerBIProvider -CommandName Write-ScubaLog -Times 1 -Exactly -ParameterFilter { $Data.AuthType -eq 'Interactive' -and $Data.Guidance -match 'Fabric Administrator role' -and $Data.Error -match '403' } } It 'logs the service principal guidance on a 403' { Mock -ModuleName ExportPowerBIProvider Get-PowerBITenantSettingsRest { throw (New-RestError -Message "The remote server returned an error: (403) Forbidden.") } Export-PowerBIProvider -CertificateBasedAuth -LicenseFound $true -AccessToken 'mock-token' -BaseUrl 'https://api.powerbi.com' | Out-Null Should -Invoke -ModuleName ExportPowerBIProvider -CommandName Write-ScubaLog -Times 1 -Exactly -ParameterFilter { $Data.AuthType -eq 'ServicePrincipal' -and $Data.Guidance -match 'security group for the service principal' -and $Data.Guidance -match 'noninteractive\.md#power-bi-tenant-setting' } } It 'logs the guidance on a 401' { Mock -ModuleName ExportPowerBIProvider Get-PowerBITenantSettingsRest { throw (New-RestError -Message "The remote server returned an error: (401) Unauthorized.") } Export-PowerBIProvider -CertificateBasedAuth -LicenseFound $true -AccessToken 'mock-token' -BaseUrl 'https://api.powerbi.com' | Out-Null Should -Invoke -ModuleName ExportPowerBIProvider -CommandName Write-ScubaLog -Times 1 -Exactly } It 'logs the guidance at Info level' { Mock -ModuleName ExportPowerBIProvider Get-PowerBITenantSettingsRest { throw (New-RestError -Message "The remote server returned an error: (403) Forbidden.") } Export-PowerBIProvider -LicenseFound $true -AccessToken 'mock-token' -BaseUrl 'https://api.powerbi.com' | Out-Null Should -Invoke -ModuleName ExportPowerBIProvider -CommandName Write-ScubaLog -Times 1 -Exactly -ParameterFilter { $Level -eq 'Info' } } It 'does not log guidance for a failure unrelated to permissions' { Mock -ModuleName ExportPowerBIProvider Get-PowerBITenantSettingsRest { throw (New-RestError -Message "The remote server returned an error: (500) Internal Server Error.") } { Export-PowerBIProvider -LicenseFound $true -AccessToken 'mock-token' -BaseUrl 'https://api.powerbi.com' | Out-Null } | Should -Not -Throw Should -Invoke -ModuleName ExportPowerBIProvider -CommandName Write-ScubaLog -Times 0 -Exactly -ParameterFilter { $Message -eq 'Power BI permissions guidance' } } It 'does not log guidance when the API call succeeds' { Mock -ModuleName ExportPowerBIProvider Get-PowerBITenantSettingsRest { New-MockAdminSettings } Export-PowerBIProvider -LicenseFound $true -AccessToken 'mock-token' -BaseUrl 'https://api.powerbi.com' | Out-Null Should -Invoke -ModuleName ExportPowerBIProvider -CommandName Write-ScubaLog -Times 0 -Exactly } # Guards the console and log copies against drifting apart. It 'logs the same guidance text it prints to the console' { Mock -ModuleName ExportPowerBIProvider Get-PowerBITenantSettingsRest { throw (New-RestError -Message "The remote server returned an error: (403) Forbidden.") } Export-PowerBIProvider -LicenseFound $true -AccessToken 'mock-token' -BaseUrl 'https://api.powerbi.com' | Out-Null Should -Invoke -ModuleName ExportPowerBIProvider -CommandName Write-Information -ParameterFilter { $MessageData -match 'Fabric Administrator role' } Should -Invoke -ModuleName ExportPowerBIProvider -CommandName Write-ScubaLog -ParameterFilter { $Data.Guidance -match 'Fabric Administrator role' } } } Context 'Command tracking' { BeforeEach { [MockCommandTracker]::Reset() Mock -ModuleName ExportPowerBIProvider Write-Information {} Mock -ModuleName ExportPowerBIProvider Write-Warning {} } It 'records Get-PowerBITenantSettingsRest as unsuccessful when the API returns 403 Forbidden' { Mock -ModuleName ExportPowerBIProvider Get-PowerBITenantSettingsRest { throw (New-RestError -Message "The remote server returned an error: (403) Forbidden.") } Export-PowerBIProvider -LicenseFound $true -AccessToken 'mock-token' -BaseUrl 'https://api.powerbi.com' | Out-Null [MockCommandTracker]::UnSuccessful | Should -Contain 'Get-PowerBITenantSettingsRest' [MockCommandTracker]::Successful | Should -Not -Contain 'Get-PowerBITenantSettingsRest' } It 'records Get-PowerBITenantSettingsRest as unsuccessful when the API returns 401 Unauthorized' { Mock -ModuleName ExportPowerBIProvider Get-PowerBITenantSettingsRest { throw (New-RestError -Message "The remote server returned an error: (401) Unauthorized.") } Export-PowerBIProvider -CertificateBasedAuth -LicenseFound $true -AccessToken 'mock-token' -BaseUrl 'https://api.powerbi.com' | Out-Null [MockCommandTracker]::UnSuccessful | Should -Contain 'Get-PowerBITenantSettingsRest' [MockCommandTracker]::Successful | Should -Not -Contain 'Get-PowerBITenantSettingsRest' } It 'records Get-PowerBITenantSettingsRest as unsuccessful when the API fails for an unrelated reason' { Mock -ModuleName ExportPowerBIProvider Get-PowerBITenantSettingsRest { throw (New-RestError -Message "The remote server returned an error: (500) Internal Server Error.") } { Export-PowerBIProvider -LicenseFound $true -AccessToken 'mock-token' -BaseUrl 'https://api.powerbi.com' | Out-Null } | Should -Not -Throw [MockCommandTracker]::UnSuccessful | Should -Contain 'Get-PowerBITenantSettingsRest' [MockCommandTracker]::Successful | Should -Not -Contain 'Get-PowerBITenantSettingsRest' } It 'records Get-PowerBITenantSettingsRest only as successful when the API call succeeds' { Mock -ModuleName ExportPowerBIProvider Get-PowerBITenantSettingsRest { New-MockAdminSettings } Export-PowerBIProvider -LicenseFound $true -AccessToken 'mock-token' -BaseUrl 'https://api.powerbi.com' | Out-Null [MockCommandTracker]::Successful | Should -Contain 'Get-PowerBITenantSettingsRest' [MockCommandTracker]::UnSuccessful | Should -Not -Contain 'Get-PowerBITenantSettingsRest' } It 'does not double count Get-PowerBITenantSettingsRest on the failure path' { Mock -ModuleName ExportPowerBIProvider Get-PowerBITenantSettingsRest { throw (New-RestError -Message "The remote server returned an error: (403) Forbidden.") } Export-PowerBIProvider -LicenseFound $true -AccessToken 'mock-token' -BaseUrl 'https://api.powerbi.com' | Out-Null @([MockCommandTracker]::UnSuccessful).Count | Should -Be 1 } } } } AfterAll { Remove-Module ExportPowerBIProvider -Force -ErrorAction SilentlyContinue Remove-Module CommandTracker -Force -ErrorAction SilentlyContinue } # SIG # Begin signature block # MIIu9wYJKoZIhvcNAQcCoIIu6DCCLuQCAQExDzANBglghkgBZQMEAgEFADB5Bgor # BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG # KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCCl/2VE/xV9YfQf # AYlfAlt8tDA+AusqiBZ9+lGR/l0OPKCCE6MwggWQMIIDeKADAgECAhAFmxtXno4h # MuI5B72nd3VcMA0GCSqGSIb3DQEBDAUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQK # EwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNV # BAMTGERpZ2lDZXJ0IFRydXN0ZWQgUm9vdCBHNDAeFw0xMzA4MDExMjAwMDBaFw0z # ODAxMTUxMjAwMDBaMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJ # bmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0 # IFRydXN0ZWQgUm9vdCBHNDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIB # AL/mkHNo3rvkXUo8MCIwaTPswqclLskhPfKK2FnC4SmnPVirdprNrnsbhA3EMB/z # G6Q4FutWxpdtHauyefLKEdLkX9YFPFIPUh/GnhWlfr6fqVcWWVVyr2iTcMKyunWZ # anMylNEQRBAu34LzB4TmdDttceItDBvuINXJIB1jKS3O7F5OyJP4IWGbNOsFxl7s # Wxq868nPzaw0QF+xembud8hIqGZXV59UWI4MK7dPpzDZVu7Ke13jrclPXuU15zHL # 2pNe3I6PgNq2kZhAkHnDeMe2scS1ahg4AxCN2NQ3pC4FfYj1gj4QkXCrVYJBMtfb # BHMqbpEBfCFM1LyuGwN1XXhm2ToxRJozQL8I11pJpMLmqaBn3aQnvKFPObURWBf3 # JFxGj2T3wWmIdph2PVldQnaHiZdpekjw4KISG2aadMreSx7nDmOu5tTvkpI6nj3c # AORFJYm2mkQZK37AlLTSYW3rM9nF30sEAMx9HJXDj/chsrIRt7t/8tWMcCxBYKqx # YxhElRp2Yn72gLD76GSmM9GJB+G9t+ZDpBi4pncB4Q+UDCEdslQpJYls5Q5SUUd0 # viastkF13nqsX40/ybzTQRESW+UQUOsxxcpyFiIJ33xMdT9j7CFfxCBRa2+xq4aL # T8LWRV+dIPyhHsXAj6KxfgommfXkaS+YHS312amyHeUbAgMBAAGjQjBAMA8GA1Ud # EwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBTs1+OC0nFdZEzf # Lmc/57qYrhwPTzANBgkqhkiG9w0BAQwFAAOCAgEAu2HZfalsvhfEkRvDoaIAjeNk # aA9Wz3eucPn9mkqZucl4XAwMX+TmFClWCzZJXURj4K2clhhmGyMNPXnpbWvWVPjS # PMFDQK4dUPVS/JA7u5iZaWvHwaeoaKQn3J35J64whbn2Z006Po9ZOSJTROvIXQPK # 7VB6fWIhCoDIc2bRoAVgX+iltKevqPdtNZx8WorWojiZ83iL9E3SIAveBO6Mm0eB # cg3AFDLvMFkuruBx8lbkapdvklBtlo1oepqyNhR6BvIkuQkRUNcIsbiJeoQjYUIp # 5aPNoiBB19GcZNnqJqGLFNdMGbJQQXE9P01wI4YMStyB0swylIQNCAmXHE/A7msg # dDDS4Dk0EIUhFQEI6FUy3nFJ2SgXUE3mvk3RdazQyvtBuEOlqtPDBURPLDab4vri # RbgjU2wGb2dVf0a1TD9uKFp5JtKkqGKX0h7i7UqLvBv9R0oN32dmfrJbQdA75PQ7 # 9ARj6e/CVABRoIoqyc54zNXqhwQYs86vSYiv85KZtrPmYQ/ShQDnUBrkG5WdGaG5 # nLGbsQAe79APT0JsyQq87kP6OnGlyE0mpTX9iV28hWIdMtKgK1TtmlfB2/oQzxm3 # i0objwG2J5VT6LaJbVu8aNQj6ItRolb58KaAoNYes7wPD1N1KarqE3fk3oyBIa0H # EEcRrYc9B9F1vM/zZn4wggawMIIEmKADAgECAhAIrUCyYNKcTJ9ezam9k67ZMA0G # CSqGSIb3DQEBDAUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJ # bmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0 # IFRydXN0ZWQgUm9vdCBHNDAeFw0yMTA0MjkwMDAwMDBaFw0zNjA0MjgyMzU5NTla # MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UE # AxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBDb2RlIFNpZ25pbmcgUlNBNDA5NiBTSEEz # ODQgMjAyMSBDQTEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDVtC9C # 0CiteLdd1TlZG7GIQvUzjOs9gZdwxbvEhSYwn6SOaNhc9es0JAfhS0/TeEP0F9ce # 2vnS1WcaUk8OoVf8iJnBkcyBAz5NcCRks43iCH00fUyAVxJrQ5qZ8sU7H/Lvy0da # E6ZMswEgJfMQ04uy+wjwiuCdCcBlp/qYgEk1hz1RGeiQIXhFLqGfLOEYwhrMxe6T # SXBCMo/7xuoc82VokaJNTIIRSFJo3hC9FFdd6BgTZcV/sk+FLEikVoQ11vkunKoA # FdE3/hoGlMJ8yOobMubKwvSnowMOdKWvObarYBLj6Na59zHh3K3kGKDYwSNHR7Oh # D26jq22YBoMbt2pnLdK9RBqSEIGPsDsJ18ebMlrC/2pgVItJwZPt4bRc4G/rJvmM # 1bL5OBDm6s6R9b7T+2+TYTRcvJNFKIM2KmYoX7BzzosmJQayg9Rc9hUZTO1i4F4z # 8ujo7AqnsAMrkbI2eb73rQgedaZlzLvjSFDzd5Ea/ttQokbIYViY9XwCFjyDKK05 # huzUtw1T0PhH5nUwjewwk3YUpltLXXRhTT8SkXbev1jLchApQfDVxW0mdmgRQRNY # mtwmKwH0iU1Z23jPgUo+QEdfyYFQc4UQIyFZYIpkVMHMIRroOBl8ZhzNeDhFMJlP # /2NPTLuqDQhTQXxYPUez+rbsjDIJAsxsPAxWEQIDAQABo4IBWTCCAVUwEgYDVR0T # AQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUaDfg67Y7+F8Rhvv+YXsIiGX0TkIwHwYD # VR0jBBgwFoAU7NfjgtJxXWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMG # A1UdJQQMMAoGCCsGAQUFBwMDMHcGCCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYY # aHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2Fj # ZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNV # HR8EPDA6MDigNqA0hjJodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRU # cnVzdGVkUm9vdEc0LmNybDAcBgNVHSAEFTATMAcGBWeBDAEDMAgGBmeBDAEEATAN # BgkqhkiG9w0BAQwFAAOCAgEAOiNEPY0Idu6PvDqZ01bgAhql+Eg08yy25nRm95Ry # sQDKr2wwJxMSnpBEn0v9nqN8JtU3vDpdSG2V1T9J9Ce7FoFFUP2cvbaF4HZ+N3HL # IvdaqpDP9ZNq4+sg0dVQeYiaiorBtr2hSBh+3NiAGhEZGM1hmYFW9snjdufE5Btf # Q/g+lP92OT2e1JnPSt0o618moZVYSNUa/tcnP/2Q0XaG3RywYFzzDaju4ImhvTnh # OE7abrs2nfvlIVNaw8rpavGiPttDuDPITzgUkpn13c5UbdldAhQfQDN8A+KVssIh # dXNSy0bYxDQcoqVLjc1vdjcshT8azibpGL6QB7BDf5WIIIJw8MzK7/0pNVwfiThV # 9zeKiwmhywvpMRr/LhlcOXHhvpynCgbWJme3kuZOX956rEnPLqR0kq3bPKSchh/j # wVYbKyP/j7XqiHtwa+aguv06P0WmxOgWkVKLQcBIhEuWTatEQOON8BUozu3xGFYH # Ki8QxAwIZDwzj64ojDzLj4gLDb879M4ee47vtevLt/B3E+bnKD+sEq6lLyJsQfmC # XBVmzGwOysWGw/YmMwwHS6DTBwJqakAwSEs0qFEgu60bhQjiWQ1tygVQK+pKHJ6l # /aCnHwZ05/LWUpD9r4VIIflXO7ScA+2GRfS0YW6/aOImYIbqyK+p/pQd52MbOoZW # eE4wggdXMIIFP6ADAgECAhAMM6tnPejLgA9WVhXroQvSMA0GCSqGSIb3DQEBCwUA # MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UE # AxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBDb2RlIFNpZ25pbmcgUlNBNDA5NiBTSEEz # ODQgMjAyMSBDQTEwHhcNMjYwMTE0MDAwMDAwWhcNMjcwMTEzMjM1OTU5WjBfMQsw # CQYDVQQGEwJVUzEdMBsGA1UECBMURGlzdHJpY3Qgb2YgQ29sdW1iaWExEzARBgNV # BAcTCldhc2hpbmd0b24xDTALBgNVBAoTBENJU0ExDTALBgNVBAMTBENJU0EwggIi # MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCuXYolNHqlh6smLTE592waXheZ # 8VHzxeds4pMaepGuwmjf8d1jG9wUNuJX9/qb0a1dgGz5D/EAz5NRTIin4SZYQEE8 # qvdl2yQJ5uWxXIjsFbrOyc1fWscUXw0Kt7OPLOafcEkdDoe8K0tO4h2GL3RWRzjp # uLfQhhnAmD6NT1l+ughnfmarV/ODgIn/RFR4YORlu4YP2xQX6KRxeTDslg7F+z6X # +t87/U8m8gQ9XTm5kBmteP4GcE/ytnyI+ScIxNRybzGomWIBm848XDE5yYhlYQ2R # SnCoo6M4CRqp9WFGVyoLkoPP0OlxzryKWaE1/nuPbYG/kf/rUB1OhqxvSSGwmNhs # vkkjsC0Z9H5Jy6heFdoxOu/+ZQksKoP/fMvHxuCCtkIJbV8tk0oT6MQ8EJbgsWDZ # TKhui1wxW6JIZyBOMPWoZUOouOzo2h5Cz7LBPKME5FkcUzcs47lpRlDkJco4PLcj # wJSo4XPnx3G/2DIjNEFNyfKCWfH8uW6nJjmDBiveFZ2j0YvgdQ+7MOjQnw7R/MAD # DTagrKl3rLV60+X2TY6/onKhCUuU3pMAjVbOwZ3PkzDLZnsEGRfm6hgp6014aXml # t8h4nu+uC41U8vUSGHl0vqKuzvmShLmnI+Iv0l95pmnqomuCZzRDrjEoaLPx7OxL # Dy/Id9E7yQDip4jBdQIDAQABo4ICAzCCAf8wHwYDVR0jBBgwFoAUaDfg67Y7+F8R # hvv+YXsIiGX0TkIwHQYDVR0OBBYEFN30sVU+fpfQQfPQWMhkO1qd+MxoMD4GA1Ud # IAQ3MDUwMwYGZ4EMAQQBMCkwJwYIKwYBBQUHAgEWG2h0dHA6Ly93d3cuZGlnaWNl # cnQuY29tL0NQUzAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYBBQUHAwMw # gbUGA1UdHwSBrTCBqjBToFGgT4ZNaHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0Rp # Z2lDZXJ0VHJ1c3RlZEc0Q29kZVNpZ25pbmdSU0E0MDk2U0hBMzg0MjAyMUNBMS5j # cmwwU6BRoE+GTWh0dHA6Ly9jcmw0LmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0 # ZWRHNENvZGVTaWduaW5nUlNBNDA5NlNIQTM4NDIwMjFDQTEuY3JsMIGUBggrBgEF # BQcBAQSBhzCBhDAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGlnaWNlcnQuY29t # MFwGCCsGAQUFBzAChlBodHRwOi8vY2FjZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNl # cnRUcnVzdGVkRzRDb2RlU2lnbmluZ1JTQTQwOTZTSEEzODQyMDIxQ0ExLmNydDAJ # BgNVHRMEAjAAMA0GCSqGSIb3DQEBCwUAA4ICAQA77T42YiOx5wWPItgo+kvB+Gzb # ZFCRHRFfTAZZNQt9o/0CqNmyA2xFklX4t5Z9VNdiIOx14AnmJdQkcRdk3vsU5gby # jKEup7LTWtvcWrl6hQwGNt3l892BgUbPKsPBE+AriktVqn5yMSXVVzeboqsqAG8e # Syei0B54/QdgR4whfHvQ/qpCACsJTlJgAykXVgDPJNKnQ7wc17loLQutqF1JUcbO # XKWt1AA9Zas5q30LDZzZeK4B56yojK68CTQXN7toSRFIuZDMKKDfIZpCX8cmbaaO # DFOOu44/QWMv+Xc6+ISYGkrTTzqWhOqiXgLVBeXGn/WrOJJ8R29mZMneCpBesCLs # YII1gCFOo7Vt6mvOKxAPQ3KhJYBFEHkp+GI65koaQkO2xv50iLS0+/j2YC66uviU # MFe0JEOdXuE7Rn/OmWNSzQ+6kPNYDJQASQ974C3wUejJoMtGZEzoTbly/HufQTrd # rhcL2aC69CxSN+idTXPLC9UT3xo4sFdOw+hXkmbXtoB1GDsd5p1TWFgRbnTXDkbM # YMBWYVB6/Tk1bzwj4iTp4g0YrtB628FXPX/ko+JWZlv0Ea865S23w1uGlnDNVxIi # +8oi74G5DM66Q6ENt6+3WoRGRrdoyE6uCh1haY+oPYSgumb0ozzrp8tw89TRKVrK # KSXGBxlExEvjQ6dYwjGCGqowghqmAgEBMH0waTELMAkGA1UEBhMCVVMxFzAVBgNV # BAoTDkRpZ2lDZXJ0LCBJbmMuMUEwPwYDVQQDEzhEaWdpQ2VydCBUcnVzdGVkIEc0 # IENvZGUgU2lnbmluZyBSU0E0MDk2IFNIQTM4NCAyMDIxIENBMQIQDDOrZz3oy4AP # VlYV66EL0jANBglghkgBZQMEAgEFAKCBhDAYBgorBgEEAYI3AgEMMQowCKACgACh # AoAAMBkGCSqGSIb3DQEJAzEMBgorBgEEAYI3AgEEMBwGCisGAQQBgjcCAQsxDjAM # BgorBgEEAYI3AgEVMC8GCSqGSIb3DQEJBDEiBCCRUnkyQswsKljrxbe9hbexyUzD # BeF9f1nJxHbInDVtIDANBgkqhkiG9w0BAQEFAASCAgAiG+6wBPPMkNzCfhTr5Ssx # +UXcAnmcgUiIlbedf8zvDzvx0Ogan/sJwxG0TydtBDDb68Luo+tENnNs1eEu+1BZ # QzArN1eiUYwYYbkrlFV7qKmZO/POPlep74emp0kzKwy9Di//OInJIgRnolK6pOD4 # w4krzWlKa8yP6V4qwv8d1x+iHDzxJrg6vKXFCiMzss1/N8VlwJjyV60aj9NQRKFu # lR/gYUWv0tPvCErN8pQbyVoM4IOEKi4oGgY4ZPekdxTMYLpa+O98em1DidLiLoVx # jn0MDbdAbdkWtU3IVnp3p5ZRvG72WuOwo8YrKYQfayuDvoTb32L1QsBHhiDiDmB+ # UY32iUZ6tKGH7lu9ZOp21bFmyTdf+lhQ70nbZZxiBZh8/VWN9DWAvY63oyLYv2jq # 1EG4lghvfy6HQ6afXHffedsPzSMawuy3BzBEq4AGprkw8mKxrhZRLl7kQfz16+5M # vchM3t4slPpvvASri17WiOry7A7M4GnRoKxmJ8ITg9zjBGhEAhk9JfEG8rypKFgO # 8dK7utMmh5ZqNF9oQu+9n/jLgnf/svltxE8ab1m0lKBsW3XiFLUyjNL55cDGoUYF # ALrego/hi6jOS2/sGSxyt3cDzdGNJX++PyMBRn0eCrbTqLkgXIiBn3Ts+q09++Uw # gcmvFFzy487a4Cdnu9+TD6GCF3cwghdzBgorBgEEAYI3AwMBMYIXYzCCF18GCSqG # SIb3DQEHAqCCF1AwghdMAgEDMQ8wDQYJYIZIAWUDBAIBBQAweAYLKoZIhvcNAQkQ # AQSgaQRnMGUCAQEGCWCGSAGG/WwHATAxMA0GCWCGSAFlAwQCAQUABCAKotoCz2/f # I5vrF7aC1QxLptSGRjGanLR6UpJRe0s5BQIRAM70N2O5ATej9n5GJ5O3uWYYDzIw # MjYxMDA4MTkxNDI2WqCCEzowggbtMIIE1aADAgECAhAIT9wzT35FTtvDD4/5khg1 # MA0GCSqGSIb3DQEBCwUAMGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2Vy # dCwgSW5jLjFBMD8GA1UEAxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBUaW1lU3RhbXBp # bmcgUlNBNDA5NiBTSEEyNTYgMjAyNSBDQTEwHhcNMjYwODA1MDAwMDAwWhcNMzcx # MTA0MjM1OTU5WjBjMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIElu # Yy4xOzA5BgNVBAMTMkRpZ2lDZXJ0IFNIQTI1NiBSU0E0MDk2IFRpbWVzdGFtcCBS # ZXNwb25kZXIgMjAyNiAxMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA # tnum8sn+zUr41JtMZbP9OMYw+HwJDpG5xkIu/lqcfNYmMX81YmsUiHLbh9ykpeWB # GKTLhYBrAN9Tdg/QEzG32XcObmgIblnr0CoQ3WSAeDZ6nH6X6VkFyYkJw3QBJREw # vm4UhLzSxmwPA7cFKRTEOMsmEEj6qJk/dqLEAL+oQYuOwE2UuiX1Vnul8YReIyWd # 4kgLn9gq6LNXM0UplkR6jL/QHxmb6fMoGBJYbnaUI7XD6cKDpekK2SVMld4iDbze # HDtOaaxldH5IxuNusQ69nd8/ZXEiB5Hbxj3RlK13cX1W4DlFXKdv/CEhM8Cj1vvl # mvhNroyPdRGbbpBlgyf8Wdu5N6ByhFwURn0U6ozlPoxN22v+fviUhP+6DR547OZn # pBMWDfei1f5sVGwiiW/KQTWOK97g+4RJpPzPNV4VYMAwO2jM2Aty2QYPVmOQTJm0 # msuXnJrSbl2gf9JylpkJlWXqk1Q4LJsxz+TELoQCZIljbgvTJgoPU2R12ydv8i1U # qL/adelA0y7U9Pmmtbze9Xx3rtajC5SzQd1jgfwAwsa90v9YcSPdmeoyoBBA/27c # CL237l5DTYYPDLQ4ON3OLTGWnvRb6jDrf/T75gMRfUzSLCBQfBusm9+mSWRlC/Df # 6S/e9Q8i13CuhzOT2Jx+V/nlbXM4QoBwlUAhelwwJT0CAwEAAaOCAZUwggGRMAwG # A1UdEwEB/wQCMAAwHQYDVR0OBBYEFBTJY4owLtRK+26U8+bjQH717M3iMB8GA1Ud # IwQYMBaAFO9vU0rp5AZ8esrikFb2L9RJ7MtOMA4GA1UdDwEB/wQEAwIHgDAWBgNV # HSUBAf8EDDAKBggrBgEFBQcDCDCBlQYIKwYBBQUHAQEEgYgwgYUwJAYIKwYBBQUH # MAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTBdBggrBgEFBQcwAoZRaHR0cDov # L2NhY2VydHMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZEc0VGltZVN0YW1w # aW5nUlNBNDA5NlNIQTI1NjIwMjVDQTEuY3J0MF8GA1UdHwRYMFYwVKBSoFCGTmh0 # dHA6Ly9jcmwzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNFRpbWVTdGFt # cGluZ1JTQTQwOTZTSEEyNTYyMDI1Q0ExLmNybDAgBgNVHSAEGTAXMAgGBmeBDAEE # AjALBglghkgBhv1sBwEwDQYJKoZIhvcNAQELBQADggIBAI3FOmEenVIK35msCYB+ # fShAsWvSYvLBItoNdAgQ2jIqrGsVsluXMJU/+mRebBc52s6lbKAvOVPXaizmKkML # LflEEKDZQx4CkS2t8aHPjkXha3hYZ010htFa3dhNgmalH5vuWvh3tTCf4frTS7gP # tGc4Z/xaPhQ2AB1mR8eEe/WbH0RWHvVIl6VwQ3+g5FKNfN2N/DWJkf13w2H+2Gfq # Efbd35Ww8CvoYBjLNIDTadcPWdgsjsiOaK/7EsKJgLjUNIVgvcaFOLLQ/GlrA+0Z # HJoFUbOr5SJN8zykPspXIXlpDJY/gqFUZRROeab9GVgmhbdOJcD/63RhxPahFUGb # ckRONqMe6DYAv6/mOG0pWd3cPStsdcS7buj5DyniwRY8yooMH6ptx5vpP/pZzBPB # eZD2U4IsthyxB5Jaa8qrOkB5z160TXiM5ADMspZ0TfD9MJoq0tFpFPssKRFhWeED # YPvcUuN7U7lvcdHl4ezQ3NT/7Ffs1sR1yh/LRbdZ3B3Vc6q2WmD8mDC0p9kzl2o7 # 3iVtS946IkEj7FkRsZGww1teYxERROC745xrtjvcw9ZyyUjHZWGRIpJeMNsPquCD # f0fkyHtB+J4AiNZqCQk23rxh+KbpyMTNVKItJ5l92Svl20U9NbqMBOVYl1h54NEY # LJq1/xHWFKPNK903zJZA9P2DMIIGtDCCBJygAwIBAgIQDcesVwX/IZkuQEMiDDpJ # hjANBgkqhkiG9w0BAQsFADBiMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNl # cnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSEwHwYDVQQDExhEaWdp # Q2VydCBUcnVzdGVkIFJvb3QgRzQwHhcNMjUwNTA3MDAwMDAwWhcNMzgwMTE0MjM1 # OTU5WjBpMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xQTA/ # BgNVBAMTOERpZ2lDZXJ0IFRydXN0ZWQgRzQgVGltZVN0YW1waW5nIFJTQTQwOTYg # U0hBMjU2IDIwMjUgQ0ExMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA # tHgx0wqYQXK+PEbAHKx126NGaHS0URedTa2NDZS1mZaDLFTtQ2oRjzUXMmxCqvkb # sDpz4aH+qbxeLho8I6jY3xL1IusLopuW2qftJYJaDNs1+JH7Z+QdSKWM06qchUP+ # AbdJgMQB3h2DZ0Mal5kYp77jYMVQXSZH++0trj6Ao+xh/AS7sQRuQL37QXbDhAkt # VJMQbzIBHYJBYgzWIjk8eDrYhXDEpKk7RdoX0M980EpLtlrNyHw0Xm+nt5pnYJU3 # Gmq6bNMI1I7Gb5IBZK4ivbVCiZv7PNBYqHEpNVWC2ZQ8BbfnFRQVESYOszFI2Wv8 # 2wnJRfN20VRS3hpLgIR4hjzL0hpoYGk81coWJ+KdPvMvaB0WkE/2qHxJ0ucS638Z # xqU14lDnki7CcoKCz6eum5A19WZQHkqUJfdkDjHkccpL6uoG8pbF0LJAQQZxst7V # vwDDjAmSFTUms+wV/FbWBqi7fTJnjq3hj0XbQcd8hjj/q8d6ylgxCZSKi17yVp2N # L+cnT6Toy+rN+nM8M7LnLqCrO2JP3oW//1sfuZDKiDEb1AQ8es9Xr/u6bDTnYCTK # IsDq1BtmXUqEG1NqzJKS4kOmxkYp2WyODi7vQTCBZtVFJfVZ3j7OgWmnhFr4yUoz # ZtqgPrHRVHhGNKlYzyjlroPxul+bgIspzOwbtmsgY1MCAwEAAaOCAV0wggFZMBIG # A1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFO9vU0rp5AZ8esrikFb2L9RJ7MtO # MB8GA1UdIwQYMBaAFOzX44LScV1kTN8uZz/nupiuHA9PMA4GA1UdDwEB/wQEAwIB # hjATBgNVHSUEDDAKBggrBgEFBQcDCDB3BggrBgEFBQcBAQRrMGkwJAYIKwYBBQUH # MAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTBBBggrBgEFBQcwAoY1aHR0cDov # L2NhY2VydHMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZFJvb3RHNC5jcnQw # QwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lD # ZXJ0VHJ1c3RlZFJvb3RHNC5jcmwwIAYDVR0gBBkwFzAIBgZngQwBBAIwCwYJYIZI # AYb9bAcBMA0GCSqGSIb3DQEBCwUAA4ICAQAXzvsWgBz+Bz0RdnEwvb4LyLU0pn/N # 0IfFiBowf0/Dm1wGc/Do7oVMY2mhXZXjDNJQa8j00DNqhCT3t+s8G0iP5kvN2n7J # d2E4/iEIUBO41P5F448rSYJ59Ib61eoalhnd6ywFLerycvZTAz40y8S4F3/a+Z1j # EMK/DMm/axFSgoR8n6c3nuZB9BfBwAQYK9FHaoq2e26MHvVY9gCDA/JYsq7pGdog # P8HRtrYfctSLANEBfHU16r3J05qX3kId+ZOczgj5kjatVB+NdADVZKON/gnZruMv # NYY2o1f4MXRJDMdTSlOLh0HCn2cQLwQCqjFbqrXuvTPSegOOzr4EWj7PtspIHBld # NE2K9i697cvaiIo2p61Ed2p8xMJb82Yosn0z4y25xUbI7GIN/TpVfHIqQ6Ku/qjT # Y6hc3hsXMrS+U0yy+GWqAXam4ToWd2UQ1KYT70kZjE4YtL8Pbzg0c1ugMZyZZd/B # dHLiRu7hAWE6bTEm4XYRkA6Tl4KSFLFk43esaUeqGkH/wyW4N7OigizwJWeukcyI # PbAvjSabnf7+Pu0VrFgoiovRDiyx3zEdmcif/sYQsfch28bZeUz2rtY/9TCA6TD8 # dC3JE3rYkrhLULy7Dc90G6e8BlqmyIjlgp2+VqsS9/wQD7yFylIz0scmbKvFoW2j # NrbM1pD2T7m3XDCCBY0wggR1oAMCAQICEA6bGI750C3n79tQ4ghAGFowDQYJKoZI # hvcNAQEMBQAwZTELMAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZ # MBcGA1UECxMQd3d3LmRpZ2ljZXJ0LmNvbTEkMCIGA1UEAxMbRGlnaUNlcnQgQXNz # dXJlZCBJRCBSb290IENBMB4XDTIyMDgwMTAwMDAwMFoXDTMxMTEwOTIzNTk1OVow # YjELMAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZMBcGA1UECxMQ # d3d3LmRpZ2ljZXJ0LmNvbTEhMB8GA1UEAxMYRGlnaUNlcnQgVHJ1c3RlZCBSb290 # IEc0MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAv+aQc2jeu+RdSjww # IjBpM+zCpyUuySE98orYWcLhKac9WKt2ms2uexuEDcQwH/MbpDgW61bGl20dq7J5 # 8soR0uRf1gU8Ug9SH8aeFaV+vp+pVxZZVXKvaJNwwrK6dZlqczKU0RBEEC7fgvMH # hOZ0O21x4i0MG+4g1ckgHWMpLc7sXk7Ik/ghYZs06wXGXuxbGrzryc/NrDRAX7F6 # Zu53yEioZldXn1RYjgwrt0+nMNlW7sp7XeOtyU9e5TXnMcvak17cjo+A2raRmECQ # ecN4x7axxLVqGDgDEI3Y1DekLgV9iPWCPhCRcKtVgkEy19sEcypukQF8IUzUvK4b # A3VdeGbZOjFEmjNAvwjXWkmkwuapoGfdpCe8oU85tRFYF/ckXEaPZPfBaYh2mHY9 # WV1CdoeJl2l6SPDgohIbZpp0yt5LHucOY67m1O+SkjqePdwA5EUlibaaRBkrfsCU # tNJhbesz2cXfSwQAzH0clcOP9yGyshG3u3/y1YxwLEFgqrFjGESVGnZifvaAsPvo # ZKYz0YkH4b235kOkGLimdwHhD5QMIR2yVCkliWzlDlJRR3S+Jqy2QXXeeqxfjT/J # vNNBERJb5RBQ6zHFynIWIgnffEx1P2PsIV/EIFFrb7GrhotPwtZFX50g/KEexcCP # orF+CiaZ9eRpL5gdLfXZqbId5RsCAwEAAaOCATowggE2MA8GA1UdEwEB/wQFMAMB # Af8wHQYDVR0OBBYEFOzX44LScV1kTN8uZz/nupiuHA9PMB8GA1UdIwQYMBaAFEXr # oq/0ksuCMS1Ri6enIZ3zbcgPMA4GA1UdDwEB/wQEAwIBhjB5BggrBgEFBQcBAQRt # MGswJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTBDBggrBgEF # BQcwAoY3aHR0cDovL2NhY2VydHMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0QXNzdXJl # ZElEUm9vdENBLmNydDBFBgNVHR8EPjA8MDqgOKA2hjRodHRwOi8vY3JsMy5kaWdp # Y2VydC5jb20vRGlnaUNlcnRBc3N1cmVkSURSb290Q0EuY3JsMBEGA1UdIAQKMAgw # BgYEVR0gADANBgkqhkiG9w0BAQwFAAOCAQEAcKC/Q1xV5zhfoKN0Gz22Ftf3v1cH # vZqsoYcs7IVeqRq7IviHGmlUIu2kiHdtvRoU9BNKei8ttzjv9P+Aufih9/Jy3iS8 # UgPITtAq3votVs/59PesMHqai7Je1M/RQ0SbQyHrlnKhSLSZy51PpwYDE3cnRNTn # f+hZqPC/Lwum6fI0POz3A8eHqNJMQBk1RmppVLC4oVaO7KTVPeix3P0c2PR3WlxU # jG/voVA9/HYJaISfb8rbII01YBwCA8sgsKxYoA5AY8WYIsGyWfVVa88nq2x2zm8j # LfR+cWojayL/ErhULSd+2DrZ8LaHlv1b0VysGMNNn3O3AamfV6peKOK5lDGCA3ww # ggN4AgEBMH0waTELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMu # MUEwPwYDVQQDEzhEaWdpQ2VydCBUcnVzdGVkIEc0IFRpbWVTdGFtcGluZyBSU0E0 # MDk2IFNIQTI1NiAyMDI1IENBMQIQCE/cM09+RU7bww+P+ZIYNTANBglghkgBZQME # AgEFAKCB0TAaBgkqhkiG9w0BCQMxDQYLKoZIhvcNAQkQAQQwHAYJKoZIhvcNAQkF # MQ8XDTI2MTAwODE5MTQyNlowKwYLKoZIhvcNAQkQAgwxHDAaMBgwFgQUUdmr2gNJ # c9hPQmaspIJI5rNpxDkwLwYJKoZIhvcNAQkEMSIEIBS8FyD1d6/D9VFD21nku5A9 # GPRE39kvvV5PpV7+MBNtMDcGCyqGSIb3DQEJEAIvMSgwJjAkMCIEIC2gnaf0Ex+f # 5y22xebpyWVnVa8EPx6nQswNISDhQev8MA0GCSqGSIb3DQEBAQUABIICAD36oUX0 # hikm0/YLoVtkMenNe16/WGBV50sjQH2XAs5diGnKahRWYQgE37jshNjfaoPAOuj9 # Sf+KK+6hyw1pkqgO5xN/jIFTOy9GtAXPPL5OU1tvSaQaXOUEy3B/JD6/HNdqTSj7 # nfs8ffoE54/PAnfF5RmNk+ZXwz53JRJ8NUmDii/BXw2dJCqAMCy4R4egNXJhY14c # HqrB3J/ggAvMXgrv5+nWIiJx/i58DGyUrDR4tWWn9FvQzHh51Z9BJUMTpqrR3Thi # D7eUgShppdxBCTW5MuUvPdteT6rBjQxhZ+0en5J9Q2EqMcvTmbOBggRTujfoxRQX # lIpJof1dtL7s+9gWT/GOnSrPRsDcw/Y5GCK6OJfJXONlX51y8QeFvc4KyOCQBZI7 # lWsJn12h3NXodJDPHg6cwb8uMct0rnOK+eoQdigJxqiju9S4V6VjFKq+d03e0ego # V+35tIxbmSIq69yxRJuk3wDL284kH0eg9G3b/omsksgay3ji38nZkmFvknN5gdjS # I5EzOonogpdnwIuI46XPqwIySOw7xG8rIB5iS5GOzRtnC8+FkOh8mPllJTZQJfVc # rzK8bp7dQFT8L6kUrKOg7ielnZCMW/p9FZLOO22siNBigDpMXrpcZbxLBh+p/Pux # U2AfH3vTr5rnlMvI0S9mFJ7HCHJ07zBLIAx7 # SIG # End signature block |