From 44fbf4d52b5eeedb9efeabcc34b18539f387b792 Mon Sep 17 00:00:00 2001 From: Johan Ljunggren Date: Sat, 10 Jan 2026 15:56:14 +0100 Subject: [PATCH 01/12] Refactor Reporting Services Integration Tests - Deleted obsolete integration test files for Post.EncryptedInformation and Post.Reinitialize. - Consolidated service account change tests for Power BI and SQL Server into new dedicated test files. - Implemented new test sequences for SQL Server 2017, 2019, and 2022 to handle service account changes and URL reservations. - Added comprehensive validation for service account accessibility and configuration after changes. - Ensured proper handling of encryption keys and database rights for different SQL Server versions. --- CHANGELOG.md | 6 + azure-pipelines.yml | 21 +- ...atabaseConnection.RS.Integration.Tests.ps1 | 57 ----- ...st.DatabaseRights.RS.Integration.Tests.ps1 | 121 ----------- ...ryptedInformation.RS.Integration.Tests.ps1 | 58 ----- ...Post.Reinitialize.RS.Integration.Tests.ps1 | 87 -------- ...untChange.PowerBI.RS.Integration.Tests.ps1 | 197 +++++++++++++++++ ...viceAccountChange.RS.Integration.Tests.ps1 | 110 ---------- ...untChange.SQL2017.RS.Integration.Tests.ps1 | 200 ++++++++++++++++++ ...ange.SQL2019-2022.RS.Integration.Tests.ps1 | 197 +++++++++++++++++ ...servationRecreate.RS.Integration.Tests.ps1 | 111 ---------- 11 files changed, 607 insertions(+), 558 deletions(-) delete mode 100644 tests/Integration/Commands/Post.DatabaseConnection.RS.Integration.Tests.ps1 delete mode 100644 tests/Integration/Commands/Post.DatabaseRights.RS.Integration.Tests.ps1 delete mode 100644 tests/Integration/Commands/Post.EncryptedInformation.RS.Integration.Tests.ps1 delete mode 100644 tests/Integration/Commands/Post.Reinitialize.RS.Integration.Tests.ps1 create mode 100644 tests/Integration/Commands/Post.ServiceAccountChange.PowerBI.RS.Integration.Tests.ps1 delete mode 100644 tests/Integration/Commands/Post.ServiceAccountChange.RS.Integration.Tests.ps1 create mode 100644 tests/Integration/Commands/Post.ServiceAccountChange.SQL2017.RS.Integration.Tests.ps1 create mode 100644 tests/Integration/Commands/Post.ServiceAccountChange.SQL2019-2022.RS.Integration.Tests.ps1 delete mode 100644 tests/Integration/Commands/Post.UrlReservationRecreate.RS.Integration.Tests.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 274e348c3..437604223 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -248,6 +248,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - SqlServerDsc + - Consolidated Reporting Services post-service-account-change integration tests + into three version-specific test files: `Post.ServiceAccountChange.SQL2017.RS`, + `Post.ServiceAccountChange.SQL2019-2022.RS`, and `Post.ServiceAccountChange.PowerBI.RS`. + SQL Server 2017 uses a workaround with `Remove-SqlDscRSEncryptedInformation` + and `Set-SqlDscRSDatabaseConnection` because the encryption key commands + fail with "Keyset does not exist" errors on SQL Server 2017. - Split the `Test_HQRM` pipeline job into two parallel jobs (`Test_QA` and `Test_HQRM`) to reduce overall pipeline execution time by approximately 15 minutes. diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 7f0452780..c4f436c05 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -589,18 +589,14 @@ stages: # Group 6 - Service account change 'tests/Integration/Commands/Set-SqlDscRSServiceAccount.Integration.Tests.ps1' 'tests/Integration/Commands/Get-SqlDscRSServiceAccount.Integration.Tests.ps1' - 'tests/Integration/Commands/Post.DatabaseRights.RS.Integration.Tests.ps1' - 'tests/Integration/Commands/Post.EncryptedInformation.RS.Integration.Tests.ps1' - 'tests/Integration/Commands/Post.DatabaseConnection.RS.Integration.Tests.ps1' - 'tests/Integration/Commands/Remove-SqlDscRSEncryptionKey.Integration.Tests.ps1' - 'tests/Integration/Commands/New-SqlDscRSEncryptionKey.Integration.Tests.ps1' - 'tests/Integration/Commands/Post.UrlReservationRecreate.RS.Integration.Tests.ps1' - 'tests/Integration/Commands/Post.Reinitialize.RS.Integration.Tests.ps1' - 'tests/Integration/Commands/Post.ServiceAccountChange.RS.Integration.Tests.ps1' + 'tests/Integration/Commands/Post.ServiceAccountChange.SQL2017.RS.Integration.Tests.ps1' + 'tests/Integration/Commands/Post.ServiceAccountChange.SQL2019-2022.RS.Integration.Tests.ps1' # Group 8 'tests/Integration/Commands/Repair-SqlDscReportingService.Integration.Tests.ps1' 'tests/Integration/Commands/Remove-SqlDscRSUrlReservation.Integration.Tests.ps1' 'tests/Integration/Commands/Remove-SqlDscRSEncryptedInformation.Integration.Tests.ps1' + 'tests/Integration/Commands/Remove-SqlDscRSEncryptionKey.Integration.Tests.ps1' + 'tests/Integration/Commands/New-SqlDscRSEncryptionKey.Integration.Tests.ps1' # Group 9 'tests/Integration/Commands/Uninstall-SqlDscReportingService.Integration.Tests.ps1' ) @@ -690,16 +686,13 @@ stages: # Group 6 - Service account change 'tests/Integration/Commands/Set-SqlDscRSServiceAccount.Integration.Tests.ps1' 'tests/Integration/Commands/Get-SqlDscRSServiceAccount.Integration.Tests.ps1' - 'tests/Integration/Commands/Post.DatabaseRights.RS.Integration.Tests.ps1' - 'tests/Integration/Commands/Remove-SqlDscRSEncryptionKey.Integration.Tests.ps1' - 'tests/Integration/Commands/New-SqlDscRSEncryptionKey.Integration.Tests.ps1' - 'tests/Integration/Commands/Post.UrlReservationRecreate.RS.Integration.Tests.ps1' - 'tests/Integration/Commands/Post.Reinitialize.RS.Integration.Tests.ps1' - 'tests/Integration/Commands/Post.ServiceAccountChange.RS.Integration.Tests.ps1' + 'tests/Integration/Commands/Post.ServiceAccountChange.PowerBI.RS.Integration.Tests.ps1' # Group 8 'tests/Integration/Commands/Repair-SqlDscPowerBIReportServer.Integration.Tests.ps1' 'tests/Integration/Commands/Remove-SqlDscRSUrlReservation.Integration.Tests.ps1' 'tests/Integration/Commands/Remove-SqlDscRSEncryptedInformation.Integration.Tests.ps1' + 'tests/Integration/Commands/Remove-SqlDscRSEncryptionKey.Integration.Tests.ps1' + 'tests/Integration/Commands/New-SqlDscRSEncryptionKey.Integration.Tests.ps1' # Group 9 'tests/Integration/Commands/Uninstall-SqlDscPowerBIReportServer.Integration.Tests.ps1' ) diff --git a/tests/Integration/Commands/Post.DatabaseConnection.RS.Integration.Tests.ps1 b/tests/Integration/Commands/Post.DatabaseConnection.RS.Integration.Tests.ps1 deleted file mode 100644 index 7e0f6542d..000000000 --- a/tests/Integration/Commands/Post.DatabaseConnection.RS.Integration.Tests.ps1 +++ /dev/null @@ -1,57 +0,0 @@ -[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Suppressing this rule because Script Analyzer does not understand Pester syntax.')] -param () - -BeforeDiscovery { - try - { - if (-not (Get-Module -Name 'DscResource.Test')) - { - # Assumes dependencies have been resolved, so if this module is not available, run 'noop' task. - if (-not (Get-Module -Name 'DscResource.Test' -ListAvailable)) - { - # Redirect all streams to $null, except the error stream (stream 2) - & "$PSScriptRoot/../../../build.ps1" -Tasks 'noop' 3>&1 4>&1 5>&1 6>&1 > $null - } - - # If the dependencies have not been resolved, this will throw an error. - Import-Module -Name 'DscResource.Test' -Force -ErrorAction 'Stop' - } - } - catch [System.IO.FileNotFoundException] - { - throw 'DscResource.Test module dependency not found. Please run ".\build.ps1 -ResolveDependency -Tasks noop" first.' - } -} - -BeforeAll { - $script:moduleName = 'SqlServerDsc' - - # Do not use -Force. Doing so, or unloading the module in AfterAll, causes - # PowerShell class types to get new identities, breaking type comparisons. - Import-Module -Name $script:moduleName -ErrorAction 'Stop' -} - -<# - .NOTES - This test is specifically for SQL Server 2017 where the encryption key - operations have issues after removing encrypted information. - - This test runs Set-SqlDscRSDatabaseConnection to re-establish the database - connection after Post.EncryptedInformation.RS.Integration.Tests.ps1 has - removed encrypted information. - - This test runs after Post.EncryptedInformation.RS.Integration.Tests.ps1. -#> -Describe 'Post.DatabaseConnection.RS' { - Context 'When re-establishing database connection for SQL Server Reporting Services on SQL Server 2017' -Tag @('Integration_SQL2017_RS') { - BeforeAll { - $script:configuration = Get-SqlDscRSConfiguration -InstanceName 'SSRS' -ErrorAction 'Stop' - - $script:computerName = Get-ComputerName - } - - It 'Should set the database connection' { - $script:configuration | Set-SqlDscRSDatabaseConnection -ServerName $script:computerName -InstanceName 'RSDB' -DatabaseName 'ReportServer' -Force -ErrorAction 'Stop' - } - } -} diff --git a/tests/Integration/Commands/Post.DatabaseRights.RS.Integration.Tests.ps1 b/tests/Integration/Commands/Post.DatabaseRights.RS.Integration.Tests.ps1 deleted file mode 100644 index 709c0e758..000000000 --- a/tests/Integration/Commands/Post.DatabaseRights.RS.Integration.Tests.ps1 +++ /dev/null @@ -1,121 +0,0 @@ -[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Suppressing this rule because Script Analyzer does not understand Pester syntax.')] -param () - -BeforeDiscovery { - try - { - if (-not (Get-Module -Name 'DscResource.Test')) - { - # Assumes dependencies have been resolved, so if this module is not available, run 'noop' task. - if (-not (Get-Module -Name 'DscResource.Test' -ListAvailable)) - { - # Redirect all streams to $null, except the error stream (stream 2) - & "$PSScriptRoot/../../../build.ps1" -Tasks 'noop' 3>&1 4>&1 5>&1 6>&1 > $null - } - - # If the dependencies have not been resolved, this will throw an error. - Import-Module -Name 'DscResource.Test' -Force -ErrorAction 'Stop' - } - } - catch [System.IO.FileNotFoundException] - { - throw 'DscResource.Test module dependency not found. Please run ".\build.ps1 -ResolveDependency -Tasks noop" first.' - } -} - -BeforeAll { - $script:moduleName = 'SqlServerDsc' - - Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath '../../TestHelpers/CommonTestHelper.psm1') - - # Do not use -Force. Doing so, or unloading the module in AfterAll, causes - # PowerShell class types to get new identities, breaking type comparisons. - Import-Module -Name $script:moduleName -ErrorAction 'Stop' -} - -<# - .NOTES - This test file grants database rights to the new service account after - the service account has been changed. The new service account needs - database permissions to access the ReportServer and ReportServerTempDB - databases. - - This test runs after Set-SqlDscRSServiceAccount and Get-SqlDscRSServiceAccount - tests, and before Post.UrlReservationRecreate.RS to ensure the service - account has database access before testing accessibility. -#> -Describe 'Post.DatabaseRights.RS' -Tag @('Integration_SQL2017_RS', 'Integration_SQL2019_RS', 'Integration_SQL2022_RS', 'Integration_PowerBI') { - BeforeAll { - if (Test-ContinuousIntegrationTaskCategory -Category 'Integration_PowerBI') - { - $script:instanceName = 'PBIRS' - } - else - { - # Default to SSRS for SQL2017_RS, SQL2019_RS, SQL2022_RS - $script:instanceName = 'SSRS' - } - - $script:configuration = Get-SqlDscRSConfiguration -InstanceName $script:instanceName -ErrorAction 'Stop' - - # Get the Reporting Services service account from the configuration object. - $script:serviceAccount = $script:configuration.WindowsServiceIdentityActual - - # Get database name from configuration - $script:databaseName = $script:configuration.DatabaseName - - Write-Verbose -Message "Instance: $script:instanceName, Database: $script:databaseName, ServiceAccount: $script:serviceAccount" -Verbose - } - - Context 'When granting database rights to the new service account' { - It 'Should create a SQL Server login for the new service account' { - # Use the RSDB instance that hosts the ReportServer database - $serverObject = Connect-SqlDscDatabaseEngine -ServerName 'localhost' -InstanceName 'RSDB' -ErrorAction 'Stop' - - try - { - # The login must exist before granting database rights - $null = New-SqlDscLogin -ServerObject $serverObject -Name $script:serviceAccount -WindowsUser -Force -ErrorAction 'Stop' - } - finally - { - Disconnect-SqlDscDatabaseEngine -ServerObject $serverObject -ErrorAction 'SilentlyContinue' - } - } - - It 'Should generate database rights script for the new service account' { - # When IsWindowsUser is set to false, the SQL Server user must already exist on the SQL Server for the script to run successfully. - $script:databaseRightsScript = $script:configuration | - Request-SqlDscRSDatabaseRightsScript -DatabaseName $script:databaseName -UserName $script:serviceAccount -ErrorAction 'Stop' - - $script:databaseRightsScript | Should -Not -BeNullOrEmpty -Because 'the database rights script should be generated' - } - - It 'Should execute the database rights script against the database' { - # Use the RSDB instance that hosts the ReportServer database - $serverObject = Connect-SqlDscDatabaseEngine -ServerName 'localhost' -InstanceName 'RSDB' -ErrorAction 'Stop' - - try - { - $invokeSqlDscQueryParameters = @{ - ServerName = 'localhost' - InstanceName = 'RSDB' - DatabaseName = 'master' - Query = $script:databaseRightsScript - Force = $true - ErrorAction = 'Stop' - } - - Invoke-SqlDscQuery @invokeSqlDscQueryParameters - } - finally - { - Disconnect-SqlDscDatabaseEngine -ServerObject $serverObject -ErrorAction 'SilentlyContinue' - } - } - - It 'Should restart the Reporting Services service to apply changes' { - $null = $script:configuration | Restart-SqlDscRSService -Force -ErrorAction 'Stop' - } - } -} diff --git a/tests/Integration/Commands/Post.EncryptedInformation.RS.Integration.Tests.ps1 b/tests/Integration/Commands/Post.EncryptedInformation.RS.Integration.Tests.ps1 deleted file mode 100644 index 9c16677c0..000000000 --- a/tests/Integration/Commands/Post.EncryptedInformation.RS.Integration.Tests.ps1 +++ /dev/null @@ -1,58 +0,0 @@ -[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Suppressing this rule because Script Analyzer does not understand Pester syntax.')] -param () - -BeforeDiscovery { - try - { - if (-not (Get-Module -Name 'DscResource.Test')) - { - # Assumes dependencies have been resolved, so if this module is not available, run 'noop' task. - if (-not (Get-Module -Name 'DscResource.Test' -ListAvailable)) - { - # Redirect all streams to $null, except the error stream (stream 2) - & "$PSScriptRoot/../../../build.ps1" -Tasks 'noop' 3>&1 4>&1 5>&1 6>&1 > $null - } - - # If the dependencies have not been resolved, this will throw an error. - Import-Module -Name 'DscResource.Test' -Force -ErrorAction 'Stop' - } - } - catch [System.IO.FileNotFoundException] - { - throw 'DscResource.Test module dependency not found. Please run ".\build.ps1 -ResolveDependency -Tasks noop" first.' - } -} - -BeforeAll { - $script:moduleName = 'SqlServerDsc' - - # Do not use -Force. Doing so, or unloading the module in AfterAll, causes - # PowerShell class types to get new identities, breaking type comparisons. - Import-Module -Name $script:moduleName -ErrorAction 'Stop' -} - -<# - .NOTES - This test is specifically for SQL Server 2017 where the encryption key - operations (Remove-SqlDscRSEncryptionKey and New-SqlDscRSEncryptionKey) - fail with "rsCannotValidateEncryptedData" and "Keyset does not exist" - errors. - - As a workaround for SQL Server 2017, this test removes all encrypted - information from the report server database using - Remove-SqlDscRSEncryptedInformation. - - This test runs after Post.DatabaseRights.RS.Integration.Tests.ps1 and - before New-SqlDscRSEncryptionKey.Integration.Tests.ps1. -#> -Describe 'Post.EncryptedInformation.RS' { - Context 'When removing encrypted information for SQL Server Reporting Services on SQL Server 2017' -Tag @('Integration_SQL2017_RS') { - BeforeAll { - $script:configuration = Get-SqlDscRSConfiguration -InstanceName 'SSRS' -ErrorAction 'Stop' - } - - It 'Should remove the encrypted information' { - $null = $script:configuration | Remove-SqlDscRSEncryptedInformation -Force -ErrorAction 'Stop' - } - } -} diff --git a/tests/Integration/Commands/Post.Reinitialize.RS.Integration.Tests.ps1 b/tests/Integration/Commands/Post.Reinitialize.RS.Integration.Tests.ps1 deleted file mode 100644 index f3971e1c0..000000000 --- a/tests/Integration/Commands/Post.Reinitialize.RS.Integration.Tests.ps1 +++ /dev/null @@ -1,87 +0,0 @@ -[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Suppressing this rule because Script Analyzer does not understand Pester syntax.')] -param () - -BeforeDiscovery { - try - { - if (-not (Get-Module -Name 'DscResource.Test')) - { - # Assumes dependencies have been resolved, so if this module is not available, run 'noop' task. - if (-not (Get-Module -Name 'DscResource.Test' -ListAvailable)) - { - # Redirect all streams to $null, except the error stream (stream 2) - & "$PSScriptRoot/../../../build.ps1" -Tasks 'noop' 3>&1 4>&1 5>&1 6>&1 > $null - } - - # If the dependencies have not been resolved, this will throw an error. - Import-Module -Name 'DscResource.Test' -Force -ErrorAction 'Stop' - } - } - catch [System.IO.FileNotFoundException] - { - throw 'DscResource.Test module dependency not found. Please run ".\build.ps1 -ResolveDependency -Tasks noop" first.' - } -} - -BeforeAll { - $script:moduleName = 'SqlServerDsc' - - Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath '../../TestHelpers/CommonTestHelper.psm1') - - # Do not use -Force. Doing so, or unloading the module in AfterAll, causes - # PowerShell class types to get new identities, breaking type comparisons. - Import-Module -Name $script:moduleName -ErrorAction 'Stop' -} - -<# - .NOTES - This test file re-initializes Reporting Services after the service account - has been changed and URL reservations have been recreated. This ensures - that the Reporting Services instance is fully functional with the new - service account. - - This test runs after Post.UrlReservationRecreate.RS and before - Post.ServiceAccountChange.RS to ensure the instance is properly - initialized before testing accessibility. -#> -Describe 'Post.Reinitialize.RS' -Tag @('Integration_SQL2017_RS', 'Integration_SQL2019_RS', 'Integration_SQL2022_RS', 'Integration_PowerBI') { - BeforeAll { - if (Test-ContinuousIntegrationTaskCategory -Category 'Integration_PowerBI') - { - $script:instanceName = 'PBIRS' - } - else - { - # Default to SSRS for SQL2017_RS, SQL2019_RS, SQL2022_RS - $script:instanceName = 'SSRS' - } - - $script:configuration = Get-SqlDscRSConfiguration -InstanceName $script:instanceName -ErrorAction 'Stop' - - # Get the Reporting Services service account from the configuration object. - $script:serviceAccount = $script:configuration.WindowsServiceIdentityActual - - Write-Verbose -Message "Instance: $script:instanceName, ServiceAccount: $script:serviceAccount" -Verbose - } - - Context 'When re-initializing Reporting Services after service account change' { - It 'Should re-initialize the Reporting Services instance' { - $script:configuration | Initialize-SqlDscRS -Force -ErrorAction 'Stop' - } - - It 'Should have an initialized instance after re-initialization' { - # Refresh the configuration after initialization - $configuration = Get-SqlDscRSConfiguration -InstanceName $script:instanceName -ErrorAction 'Stop' - - $isInitialized = $configuration | Test-SqlDscRSInitialized -ErrorAction 'Stop' - - $isInitialized | Should -BeTrue -Because 'the instance should be initialized after re-initialization' - - Write-Verbose -Message "Instance initialized: $isInitialized" -Verbose - } - - It 'Should restart the Reporting Services service' { - $null = $script:configuration | Restart-SqlDscRSService -Force -ErrorAction 'Stop' - } - } -} diff --git a/tests/Integration/Commands/Post.ServiceAccountChange.PowerBI.RS.Integration.Tests.ps1 b/tests/Integration/Commands/Post.ServiceAccountChange.PowerBI.RS.Integration.Tests.ps1 new file mode 100644 index 000000000..65d17c279 --- /dev/null +++ b/tests/Integration/Commands/Post.ServiceAccountChange.PowerBI.RS.Integration.Tests.ps1 @@ -0,0 +1,197 @@ +[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Suppressing this rule because Script Analyzer does not understand Pester syntax.')] +param () + +BeforeDiscovery { + try + { + if (-not (Get-Module -Name 'DscResource.Test')) + { + # Assumes dependencies have been resolved, so if this module is not available, run 'noop' task. + if (-not (Get-Module -Name 'DscResource.Test' -ListAvailable)) + { + # Redirect all streams to $null, except the error stream (stream 2) + & "$PSScriptRoot/../../../build.ps1" -Tasks 'noop' 3>&1 4>&1 5>&1 6>&1 > $null + } + + # If the dependencies have not been resolved, this will throw an error. + Import-Module -Name 'DscResource.Test' -Force -ErrorAction 'Stop' + } + } + catch [System.IO.FileNotFoundException] + { + throw 'DscResource.Test module dependency not found. Please run ".\build.ps1 -ResolveDependency -Tasks noop" first.' + } +} + +BeforeAll { + $script:moduleName = 'SqlServerDsc' + + Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath '../../TestHelpers/CommonTestHelper.psm1') + + # Do not use -Force. Doing so, or unloading the module in AfterAll, causes + # PowerShell class types to get new identities, breaking type comparisons. + Import-Module -Name $script:moduleName -ErrorAction 'Stop' +} + +<# + .NOTES + This consolidated test file performs all post-service-account-change + operations for Power BI Report Server. + After changing the service account, these commands must run in sequence + to restore site accessibility. + + Power BI Report Server uses the encryption key workflow (Remove-SqlDscRSEncryptionKey + and New-SqlDscRSEncryptionKey) which works correctly, same as SQL Server 2019+. + + Command sequence: + 1. New-SqlDscLogin - Create SQL login for service account + 2. Request-SqlDscRSDatabaseRightsScript - Generate database rights script + 3. Invoke-SqlDscQuery - Execute database rights script + 4. Restart-SqlDscRSService - Restart service after granting rights + 5. Remove-SqlDscRSEncryptionKey - Remove encryption key + 6. New-SqlDscRSEncryptionKey - Create new encryption key + 7. Set-SqlDscRSUrlReservation -RecreateExisting - Recreate URL reservations + 8. Initialize-SqlDscRS - Re-initialize Reporting Services + 9. Restart-SqlDscRSService - Final service restart + 10. Test-SqlDscRSAccessible - Validate site accessibility +#> +Describe 'Post.ServiceAccountChange.PowerBI.RS' -Tag @('Integration_PowerBI') { + BeforeAll { + $script:instanceName = 'PBIRS' + $script:configuration = Get-SqlDscRSConfiguration -InstanceName $script:instanceName -ErrorAction 'Stop' + + # Get the Reporting Services service account from the configuration object. + $script:serviceAccount = $script:configuration.WindowsServiceIdentityActual + + # Get database name from configuration + $script:databaseName = $script:configuration.DatabaseName + + $script:computerName = Get-ComputerName + $script:expectedServiceAccount = '{0}\svc-PBIRS' -f $script:computerName + + Write-Verbose -Message "Instance: $script:instanceName, Database: $script:databaseName, ServiceAccount: $script:serviceAccount" -Verbose + } + + Context 'When granting database rights to the new service account' { + BeforeAll { + # Connect to the database engine for the RS database instance. + $script:serverObject = Connect-SqlDscDatabaseEngine -ServerName 'localhost' -InstanceName 'PBIRSDB' -ErrorAction 'Stop' + } + + AfterAll { + Disconnect-SqlDscDatabaseEngine -ServerObject $script:serverObject -ErrorAction 'SilentlyContinue' + } + + It 'Should create a SQL Server login for the new service account' { + $null = New-SqlDscLogin -ServerObject $script:serverObject -Name $script:serviceAccount -WindowsUser -Force -ErrorAction 'Stop' + } + + It 'Should generate database rights script for the new service account' { + $script:databaseRightsScript = $script:configuration | + Request-SqlDscRSDatabaseRightsScript -DatabaseName $script:databaseName -UserName $script:serviceAccount -ErrorAction 'Stop' + + $script:databaseRightsScript | Should -Not -BeNullOrEmpty -Because 'the database rights script should be generated' + } + + It 'Should execute the database rights script against the database' { + $invokeSqlDscQueryParameters = @{ + ServerName = 'localhost' + InstanceName = 'PBIRSDB' + DatabaseName = 'master' + Query = $script:databaseRightsScript + Force = $true + ErrorAction = 'Stop' + } + + Invoke-SqlDscQuery @invokeSqlDscQueryParameters + } + + It 'Should restart the Reporting Services service after granting rights' { + $null = $script:configuration | Restart-SqlDscRSService -Force -ErrorAction 'Stop' + } + } + + Context 'When regenerating encryption key after service account change' { + It 'Should remove the encryption key' { + # Refresh configuration after restart + $script:configuration = Get-SqlDscRSConfiguration -InstanceName $script:instanceName -ErrorAction 'Stop' + + $null = $script:configuration | Remove-SqlDscRSEncryptionKey -Force -ErrorAction 'Stop' + } + + It 'Should create a new encryption key' { + # Refresh configuration after removing encryption key + $script:configuration = Get-SqlDscRSConfiguration -InstanceName $script:instanceName -ErrorAction 'Stop' + + $null = $script:configuration | New-SqlDscRSEncryptionKey -Force -ErrorAction 'Stop' + } + } + + Context 'When recreating URL reservations after service account change' { + It 'Should recreate all URL reservations' { + # Refresh configuration after encryption key change + $script:configuration = Get-SqlDscRSConfiguration -InstanceName $script:instanceName -ErrorAction 'Stop' + + $null = $script:configuration | Set-SqlDscRSUrlReservation -RecreateExisting -Force -ErrorAction 'Stop' + } + } + + Context 'When re-initializing Reporting Services after service account change' { + It 'Should re-initialize the Reporting Services instance' { + # Refresh configuration + $script:configuration = Get-SqlDscRSConfiguration -InstanceName $script:instanceName -ErrorAction 'Stop' + + $script:configuration | Initialize-SqlDscRS -Force -ErrorAction 'Stop' + } + + It 'Should have an initialized instance after re-initialization' { + $configuration = Get-SqlDscRSConfiguration -InstanceName $script:instanceName -ErrorAction 'Stop' + + $isInitialized = $configuration | Test-SqlDscRSInitialized -ErrorAction 'Stop' + + $isInitialized | Should -BeTrue -Because 'the instance should be initialized after re-initialization' + } + + It 'Should restart the Reporting Services service' { + $script:configuration = Get-SqlDscRSConfiguration -InstanceName $script:instanceName -ErrorAction 'Stop' + + $null = $script:configuration | Restart-SqlDscRSService -Force -ErrorAction 'Stop' + } + } + + Context 'When validating Reporting Services accessibility after service account change' { + It 'Should have the expected service account set' { + $script:configuration = Get-SqlDscRSConfiguration -InstanceName $script:instanceName -ErrorAction 'Stop' + + $currentServiceAccount = $script:configuration | Get-SqlDscRSServiceAccount -ErrorAction 'Stop' + + $currentServiceAccount | Should -BeExactly $script:expectedServiceAccount -Because 'the service account should have been changed' + } + + It 'Should have an initialized instance' { + $isInitialized = $script:configuration | Test-SqlDscRSInitialized -ErrorAction 'Stop' + + $isInitialized | Should -BeTrue -Because 'the instance should remain initialized after service account change' + } + + It 'Should have all configured sites accessible after service account change' { + $results = $script:configuration | Test-SqlDscRSAccessible -Detailed -TimeoutSeconds 240 -RetryIntervalSeconds 10 -ErrorAction 'Stop' -Verbose + + Write-Verbose -Message "Accessibility results: $($results | ConvertTo-Json -Compress)" -Verbose + + $urlReservations = $script:configuration | Get-SqlDscRSUrlReservation -ErrorAction 'Stop' + $expectedApplications = $urlReservations.Application | Select-Object -Unique + + $results | Should -Not -BeNullOrEmpty -Because 'the command should return site accessibility results' + + foreach ($application in $expectedApplications) + { + $siteResult = $results | Where-Object -FilterScript { $_.Site -eq $application } + + $siteResult | Should -Not -BeNullOrEmpty -Because "the '$application' site should have a result" + $siteResult.Accessible | Should -BeTrue -Because "the '$application' site should be accessible after service account change" + $siteResult.StatusCode | Should -Be 200 -Because "the '$application' site should return HTTP 200 after service account change" + } + } + } +} diff --git a/tests/Integration/Commands/Post.ServiceAccountChange.RS.Integration.Tests.ps1 b/tests/Integration/Commands/Post.ServiceAccountChange.RS.Integration.Tests.ps1 deleted file mode 100644 index 74b8d847d..000000000 --- a/tests/Integration/Commands/Post.ServiceAccountChange.RS.Integration.Tests.ps1 +++ /dev/null @@ -1,110 +0,0 @@ -[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Suppressing this rule because Script Analyzer does not understand Pester syntax.')] -param () - -BeforeDiscovery { - try - { - if (-not (Get-Module -Name 'DscResource.Test')) - { - # Assumes dependencies have been resolved, so if this module is not available, run 'noop' task. - if (-not (Get-Module -Name 'DscResource.Test' -ListAvailable)) - { - # Redirect all streams to $null, except the error stream (stream 2) - & "$PSScriptRoot/../../../build.ps1" -Tasks 'noop' 3>&1 4>&1 5>&1 6>&1 > $null - } - - # If the dependencies have not been resolved, this will throw an error. - Import-Module -Name 'DscResource.Test' -Force -ErrorAction 'Stop' - } - } - catch [System.IO.FileNotFoundException] - { - throw 'DscResource.Test module dependency not found. Please run ".\build.ps1 -ResolveDependency -Tasks noop" first.' - } -} - -BeforeAll { - $script:moduleName = 'SqlServerDsc' - - Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath '../../TestHelpers/CommonTestHelper.psm1') - - # Do not use -Force. Doing so, or unloading the module in AfterAll, causes - # PowerShell class types to get new identities, breaking type comparisons. - Import-Module -Name $script:moduleName -ErrorAction 'Stop' -} - -<# - .NOTES - This test file validates that Reporting Services sites are accessible - after the service account has been changed. It runs after the - Set-SqlDscRSServiceAccount and Get-SqlDscRSServiceAccount tests to - verify the RS configuration remains functional after a service account - change. - - Uses URL reservations from the configuration CIM instance via the - Configuration parameter set of Test-SqlDscRSAccessible. -#> -Describe 'Post.ServiceAccountChange.RS' -Tag @('Integration_SQL2017_RS', 'Integration_SQL2019_RS', 'Integration_SQL2022_RS', 'Integration_PowerBI') { - BeforeAll { - if (Test-ContinuousIntegrationTaskCategory -Category 'Integration_PowerBI') - { - $script:instanceName = 'PBIRS' - } - else - { - # Default to SSRS for SQL2017_RS, SQL2019_RS, SQL2022_RS - $script:instanceName = 'SSRS' - } - - $computerName = Get-ComputerName - $script:expectedServiceAccount = '{0}\svc-RS' -f $computerName - - $script:configuration = Get-SqlDscRSConfiguration -InstanceName $script:instanceName -ErrorAction 'Stop' - - # Get expected URL reservations - $script:urlReservations = $script:configuration | Get-SqlDscRSUrlReservation -ErrorAction 'Stop' - - Write-Verbose -Message "Instance: $script:instanceName, URL Reservations: Application=$($script:urlReservations.Application -join ',') UrlString=$($script:urlReservations.UrlString -join ',')" -Verbose - } - - Context 'When validating Reporting Services accessibility after service account change' { - It 'Should have the expected service account set' { - $currentServiceAccount = $script:configuration | Get-SqlDscRSServiceAccount -ErrorAction 'Stop' - - $currentServiceAccount | Should -BeExactly $script:expectedServiceAccount -Because 'the service account should have been changed by Set-SqlDscRSServiceAccount tests' - } - - It 'Should have an initialized instance' { - $isInitialized = $script:configuration | Test-SqlDscRSInitialized -ErrorAction 'Stop' - - $isInitialized | Should -BeTrue -Because 'the instance should remain initialized after service account change' - } - - It 'Should have URL reservations configured' { - $script:urlReservations | Should -Not -BeNullOrEmpty - $script:urlReservations.Application | Should -Not -BeNullOrEmpty -Because 'URL reservations should have applications configured' - $script:urlReservations.UrlString | Should -Not -BeNullOrEmpty -Because 'URL reservations should have URL strings configured' - } - - It 'Should have all configured sites accessible after service account change' { - $results = $script:configuration | Test-SqlDscRSAccessible -Detailed -TimeoutSeconds 240 -RetryIntervalSeconds 10 -ErrorAction 'Stop' -Verbose - - Write-Verbose -Message "Accessibility results: $($results | ConvertTo-Json -Compress)" -Verbose - - # Verify we got results for the expected applications - $expectedApplications = $script:urlReservations.Application | Select-Object -Unique - - $results | Should -Not -BeNullOrEmpty -Because 'the command should return site accessibility results' - $results | Should -HaveCount $expectedApplications.Count -Because "we expect results for each unique application ($($expectedApplications -join ', '))" - - foreach ($application in $expectedApplications) - { - $siteResult = $results | Where-Object -FilterScript { $_.Site -eq $application } - - $siteResult | Should -Not -BeNullOrEmpty -Because "the '$application' site should have a result" - $siteResult.Accessible | Should -BeTrue -Because "the '$application' site should be accessible after service account change" - $siteResult.StatusCode | Should -Be 200 -Because "the '$application' site should return HTTP 200 after service account change" - } - } - } -} diff --git a/tests/Integration/Commands/Post.ServiceAccountChange.SQL2017.RS.Integration.Tests.ps1 b/tests/Integration/Commands/Post.ServiceAccountChange.SQL2017.RS.Integration.Tests.ps1 new file mode 100644 index 000000000..4c2a85b76 --- /dev/null +++ b/tests/Integration/Commands/Post.ServiceAccountChange.SQL2017.RS.Integration.Tests.ps1 @@ -0,0 +1,200 @@ +[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Suppressing this rule because Script Analyzer does not understand Pester syntax.')] +param () + +BeforeDiscovery { + try + { + if (-not (Get-Module -Name 'DscResource.Test')) + { + # Assumes dependencies have been resolved, so if this module is not available, run 'noop' task. + if (-not (Get-Module -Name 'DscResource.Test' -ListAvailable)) + { + # Redirect all streams to $null, except the error stream (stream 2) + & "$PSScriptRoot/../../../build.ps1" -Tasks 'noop' 3>&1 4>&1 5>&1 6>&1 > $null + } + + # If the dependencies have not been resolved, this will throw an error. + Import-Module -Name 'DscResource.Test' -Force -ErrorAction 'Stop' + } + } + catch [System.IO.FileNotFoundException] + { + throw 'DscResource.Test module dependency not found. Please run ".\build.ps1 -ResolveDependency -Tasks noop" first.' + } +} + +BeforeAll { + $script:moduleName = 'SqlServerDsc' + + Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath '../../TestHelpers/CommonTestHelper.psm1') + + # Do not use -Force. Doing so, or unloading the module in AfterAll, causes + # PowerShell class types to get new identities, breaking type comparisons. + Import-Module -Name $script:moduleName -ErrorAction 'Stop' +} + +<# + .NOTES + This consolidated test file performs all post-service-account-change + operations for SQL Server 2017 Reporting Services. After changing the + service account, these commands must run in sequence to restore site + accessibility. + + SQL Server 2017 requires a different workflow than SQL Server 2019+ + because the encryption key commands fail with "rsCannotValidateEncryptedData" + and "Keyset does not exist" errors. Instead, this workflow uses + Remove-SqlDscRSEncryptedInformation and Set-SqlDscRSDatabaseConnection + as a workaround. + + Command sequence: + 1. New-SqlDscLogin - Create SQL login for service account + 2. Request-SqlDscRSDatabaseRightsScript - Generate database rights script + 3. Invoke-SqlDscQuery - Execute database rights script + 4. Restart-SqlDscRSService - Restart service after granting rights + 5. Remove-SqlDscRSEncryptedInformation - Remove encrypted info (SQL 2017 workaround) + 6. Set-SqlDscRSDatabaseConnection - Re-establish database connection + 7. Set-SqlDscRSUrlReservation -RecreateExisting - Recreate URL reservations + 8. Initialize-SqlDscRS - Re-initialize Reporting Services + 9. Restart-SqlDscRSService - Final service restart + 10. Test-SqlDscRSAccessible - Validate site accessibility +#> +Describe 'Post.ServiceAccountChange.SQL2017.RS' -Tag @('Integration_SQL2017_RS') { + BeforeAll { + $script:instanceName = 'SSRS' + $script:configuration = Get-SqlDscRSConfiguration -InstanceName $script:instanceName -ErrorAction 'Stop' + + # Get the Reporting Services service account from the configuration object. + $script:serviceAccount = $script:configuration.WindowsServiceIdentityActual + + # Get database name from configuration + $script:databaseName = $script:configuration.DatabaseName + + $script:computerName = Get-ComputerName + $script:expectedServiceAccount = '{0}\svc-RS' -f $script:computerName + + Write-Verbose -Message "Instance: $script:instanceName, Database: $script:databaseName, ServiceAccount: $script:serviceAccount" -Verbose + } + + Context 'When granting database rights to the new service account' { + BeforeAll { + # Connect to the database engine for the RS database instance. + $script:serverObject = Connect-SqlDscDatabaseEngine -ServerName 'localhost' -InstanceName 'RSDB' -ErrorAction 'Stop' + } + + AfterAll { + Disconnect-SqlDscDatabaseEngine -ServerObject $script:serverObject -ErrorAction 'SilentlyContinue' + } + + It 'Should create a SQL Server login for the new service account' { + $null = New-SqlDscLogin -ServerObject $script:serverObject -Name $script:serviceAccount -WindowsUser -Force -ErrorAction 'Stop' + } + + It 'Should generate database rights script for the new service account' { + $script:databaseRightsScript = $script:configuration | + Request-SqlDscRSDatabaseRightsScript -DatabaseName $script:databaseName -UserName $script:serviceAccount -ErrorAction 'Stop' + + $script:databaseRightsScript | Should -Not -BeNullOrEmpty -Because 'the database rights script should be generated' + } + + It 'Should execute the database rights script against the database' { + $invokeSqlDscQueryParameters = @{ + ServerName = 'localhost' + InstanceName = 'RSDB' + DatabaseName = 'master' + Query = $script:databaseRightsScript + Force = $true + ErrorAction = 'Stop' + } + + Invoke-SqlDscQuery @invokeSqlDscQueryParameters + } + + It 'Should restart the Reporting Services service after granting rights' { + $null = $script:configuration | Restart-SqlDscRSService -Force -ErrorAction 'Stop' + } + } + + Context 'When removing encrypted information (SQL Server 2017 workaround)' { + It 'Should remove the encrypted information' { + # Refresh configuration after restart + $script:configuration = Get-SqlDscRSConfiguration -InstanceName $script:instanceName -ErrorAction 'Stop' + + $null = $script:configuration | Remove-SqlDscRSEncryptedInformation -Force -ErrorAction 'Stop' + } + + It 'Should re-establish the database connection' { + # Refresh configuration after removing encrypted information + $script:configuration = Get-SqlDscRSConfiguration -InstanceName $script:instanceName -ErrorAction 'Stop' + + $script:configuration | Set-SqlDscRSDatabaseConnection -ServerName $script:computerName -InstanceName 'RSDB' -DatabaseName 'ReportServer' -Force -ErrorAction 'Stop' + } + } + + Context 'When recreating URL reservations after service account change' { + It 'Should recreate all URL reservations' { + # Refresh configuration after database connection change + $script:configuration = Get-SqlDscRSConfiguration -InstanceName $script:instanceName -ErrorAction 'Stop' + + $null = $script:configuration | Set-SqlDscRSUrlReservation -RecreateExisting -Force -ErrorAction 'Stop' + } + } + + Context 'When re-initializing Reporting Services after service account change' { + It 'Should re-initialize the Reporting Services instance' { + # Refresh configuration + $script:configuration = Get-SqlDscRSConfiguration -InstanceName $script:instanceName -ErrorAction 'Stop' + + $script:configuration | Initialize-SqlDscRS -Force -ErrorAction 'Stop' + } + + It 'Should have an initialized instance after re-initialization' { + $configuration = Get-SqlDscRSConfiguration -InstanceName $script:instanceName -ErrorAction 'Stop' + + $isInitialized = $configuration | Test-SqlDscRSInitialized -ErrorAction 'Stop' + + $isInitialized | Should -BeTrue -Because 'the instance should be initialized after re-initialization' + } + + It 'Should restart the Reporting Services service' { + $script:configuration = Get-SqlDscRSConfiguration -InstanceName $script:instanceName -ErrorAction 'Stop' + + $null = $script:configuration | Restart-SqlDscRSService -Force -ErrorAction 'Stop' + } + } + + Context 'When validating Reporting Services accessibility after service account change' { + It 'Should have the expected service account set' { + $script:configuration = Get-SqlDscRSConfiguration -InstanceName $script:instanceName -ErrorAction 'Stop' + + $currentServiceAccount = $script:configuration | Get-SqlDscRSServiceAccount -ErrorAction 'Stop' + + $currentServiceAccount | Should -BeExactly $script:expectedServiceAccount -Because 'the service account should have been changed' + } + + It 'Should have an initialized instance' { + $isInitialized = $script:configuration | Test-SqlDscRSInitialized -ErrorAction 'Stop' + + $isInitialized | Should -BeTrue -Because 'the instance should remain initialized after service account change' + } + + It 'Should have all configured sites accessible after service account change' { + $results = $script:configuration | Test-SqlDscRSAccessible -Detailed -TimeoutSeconds 240 -RetryIntervalSeconds 10 -ErrorAction 'Stop' -Verbose + + Write-Verbose -Message "Accessibility results: $($results | ConvertTo-Json -Compress)" -Verbose + + $urlReservations = $script:configuration | Get-SqlDscRSUrlReservation -ErrorAction 'Stop' + $expectedApplications = $urlReservations.Application | Select-Object -Unique + + $results | Should -Not -BeNullOrEmpty -Because 'the command should return site accessibility results' + + foreach ($application in $expectedApplications) + { + $siteResult = $results | Where-Object -FilterScript { $_.Site -eq $application } + + $siteResult | Should -Not -BeNullOrEmpty -Because "the '$application' site should have a result" + $siteResult.Accessible | Should -BeTrue -Because "the '$application' site should be accessible after service account change" + $siteResult.StatusCode | Should -Be 200 -Because "the '$application' site should return HTTP 200 after service account change" + } + } + } +} diff --git a/tests/Integration/Commands/Post.ServiceAccountChange.SQL2019-2022.RS.Integration.Tests.ps1 b/tests/Integration/Commands/Post.ServiceAccountChange.SQL2019-2022.RS.Integration.Tests.ps1 new file mode 100644 index 000000000..ca297688a --- /dev/null +++ b/tests/Integration/Commands/Post.ServiceAccountChange.SQL2019-2022.RS.Integration.Tests.ps1 @@ -0,0 +1,197 @@ +[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Suppressing this rule because Script Analyzer does not understand Pester syntax.')] +param () + +BeforeDiscovery { + try + { + if (-not (Get-Module -Name 'DscResource.Test')) + { + # Assumes dependencies have been resolved, so if this module is not available, run 'noop' task. + if (-not (Get-Module -Name 'DscResource.Test' -ListAvailable)) + { + # Redirect all streams to $null, except the error stream (stream 2) + & "$PSScriptRoot/../../../build.ps1" -Tasks 'noop' 3>&1 4>&1 5>&1 6>&1 > $null + } + + # If the dependencies have not been resolved, this will throw an error. + Import-Module -Name 'DscResource.Test' -Force -ErrorAction 'Stop' + } + } + catch [System.IO.FileNotFoundException] + { + throw 'DscResource.Test module dependency not found. Please run ".\build.ps1 -ResolveDependency -Tasks noop" first.' + } +} + +BeforeAll { + $script:moduleName = 'SqlServerDsc' + + Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath '../../TestHelpers/CommonTestHelper.psm1') + + # Do not use -Force. Doing so, or unloading the module in AfterAll, causes + # PowerShell class types to get new identities, breaking type comparisons. + Import-Module -Name $script:moduleName -ErrorAction 'Stop' +} + +<# + .NOTES + This consolidated test file performs all post-service-account-change + operations for SQL Server 2019 and SQL Server 2022 Reporting Services. + After changing the service account, these commands must run in sequence + to restore site accessibility. + + SQL Server 2019+ uses the encryption key workflow (Remove-SqlDscRSEncryptionKey + and New-SqlDscRSEncryptionKey) which works correctly unlike SQL Server 2017. + + Command sequence: + 1. New-SqlDscLogin - Create SQL login for service account + 2. Request-SqlDscRSDatabaseRightsScript - Generate database rights script + 3. Invoke-SqlDscQuery - Execute database rights script + 4. Restart-SqlDscRSService - Restart service after granting rights + 5. Remove-SqlDscRSEncryptionKey - Remove encryption key + 6. New-SqlDscRSEncryptionKey - Create new encryption key + 7. Set-SqlDscRSUrlReservation -RecreateExisting - Recreate URL reservations + 8. Initialize-SqlDscRS - Re-initialize Reporting Services + 9. Restart-SqlDscRSService - Final service restart + 10. Test-SqlDscRSAccessible - Validate site accessibility +#> +Describe 'Post.ServiceAccountChange.SQL2019-2022.RS' -Tag @('Integration_SQL2019_RS', 'Integration_SQL2022_RS') { + BeforeAll { + $script:instanceName = 'SSRS' + $script:configuration = Get-SqlDscRSConfiguration -InstanceName $script:instanceName -ErrorAction 'Stop' + + # Get the Reporting Services service account from the configuration object. + $script:serviceAccount = $script:configuration.WindowsServiceIdentityActual + + # Get database name from configuration + $script:databaseName = $script:configuration.DatabaseName + + $script:computerName = Get-ComputerName + $script:expectedServiceAccount = '{0}\svc-RS' -f $script:computerName + + Write-Verbose -Message "Instance: $script:instanceName, Database: $script:databaseName, ServiceAccount: $script:serviceAccount" -Verbose + } + + Context 'When granting database rights to the new service account' { + BeforeAll { + # Connect to the database engine for the RS database instance. + $script:serverObject = Connect-SqlDscDatabaseEngine -ServerName 'localhost' -InstanceName 'RSDB' -ErrorAction 'Stop' + } + + AfterAll { + Disconnect-SqlDscDatabaseEngine -ServerObject $script:serverObject -ErrorAction 'SilentlyContinue' + } + + It 'Should create a SQL Server login for the new service account' { + $null = New-SqlDscLogin -ServerObject $script:serverObject -Name $script:serviceAccount -WindowsUser -Force -ErrorAction 'Stop' + } + + It 'Should generate database rights script for the new service account' { + $script:databaseRightsScript = $script:configuration | + Request-SqlDscRSDatabaseRightsScript -DatabaseName $script:databaseName -UserName $script:serviceAccount -ErrorAction 'Stop' + + $script:databaseRightsScript | Should -Not -BeNullOrEmpty -Because 'the database rights script should be generated' + } + + It 'Should execute the database rights script against the database' { + $invokeSqlDscQueryParameters = @{ + ServerName = 'localhost' + InstanceName = 'RSDB' + DatabaseName = 'master' + Query = $script:databaseRightsScript + Force = $true + ErrorAction = 'Stop' + } + + Invoke-SqlDscQuery @invokeSqlDscQueryParameters + } + + It 'Should restart the Reporting Services service after granting rights' { + $null = $script:configuration | Restart-SqlDscRSService -Force -ErrorAction 'Stop' + } + } + + Context 'When regenerating encryption key after service account change' { + It 'Should remove the encryption key' { + # Refresh configuration after restart + $script:configuration = Get-SqlDscRSConfiguration -InstanceName $script:instanceName -ErrorAction 'Stop' + + $null = $script:configuration | Remove-SqlDscRSEncryptionKey -Force -ErrorAction 'Stop' + } + + It 'Should create a new encryption key' { + # Refresh configuration after removing encryption key + $script:configuration = Get-SqlDscRSConfiguration -InstanceName $script:instanceName -ErrorAction 'Stop' + + $null = $script:configuration | New-SqlDscRSEncryptionKey -Force -ErrorAction 'Stop' + } + } + + Context 'When recreating URL reservations after service account change' { + It 'Should recreate all URL reservations' { + # Refresh configuration after encryption key change + $script:configuration = Get-SqlDscRSConfiguration -InstanceName $script:instanceName -ErrorAction 'Stop' + + $null = $script:configuration | Set-SqlDscRSUrlReservation -RecreateExisting -Force -ErrorAction 'Stop' + } + } + + Context 'When re-initializing Reporting Services after service account change' { + It 'Should re-initialize the Reporting Services instance' { + # Refresh configuration + $script:configuration = Get-SqlDscRSConfiguration -InstanceName $script:instanceName -ErrorAction 'Stop' + + $script:configuration | Initialize-SqlDscRS -Force -ErrorAction 'Stop' + } + + It 'Should have an initialized instance after re-initialization' { + $configuration = Get-SqlDscRSConfiguration -InstanceName $script:instanceName -ErrorAction 'Stop' + + $isInitialized = $configuration | Test-SqlDscRSInitialized -ErrorAction 'Stop' + + $isInitialized | Should -BeTrue -Because 'the instance should be initialized after re-initialization' + } + + It 'Should restart the Reporting Services service' { + $script:configuration = Get-SqlDscRSConfiguration -InstanceName $script:instanceName -ErrorAction 'Stop' + + $null = $script:configuration | Restart-SqlDscRSService -Force -ErrorAction 'Stop' + } + } + + Context 'When validating Reporting Services accessibility after service account change' { + It 'Should have the expected service account set' { + $script:configuration = Get-SqlDscRSConfiguration -InstanceName $script:instanceName -ErrorAction 'Stop' + + $currentServiceAccount = $script:configuration | Get-SqlDscRSServiceAccount -ErrorAction 'Stop' + + $currentServiceAccount | Should -BeExactly $script:expectedServiceAccount -Because 'the service account should have been changed' + } + + It 'Should have an initialized instance' { + $isInitialized = $script:configuration | Test-SqlDscRSInitialized -ErrorAction 'Stop' + + $isInitialized | Should -BeTrue -Because 'the instance should remain initialized after service account change' + } + + It 'Should have all configured sites accessible after service account change' { + $results = $script:configuration | Test-SqlDscRSAccessible -Detailed -TimeoutSeconds 240 -RetryIntervalSeconds 10 -ErrorAction 'Stop' -Verbose + + Write-Verbose -Message "Accessibility results: $($results | ConvertTo-Json -Compress)" -Verbose + + $urlReservations = $script:configuration | Get-SqlDscRSUrlReservation -ErrorAction 'Stop' + $expectedApplications = $urlReservations.Application | Select-Object -Unique + + $results | Should -Not -BeNullOrEmpty -Because 'the command should return site accessibility results' + + foreach ($application in $expectedApplications) + { + $siteResult = $results | Where-Object -FilterScript { $_.Site -eq $application } + + $siteResult | Should -Not -BeNullOrEmpty -Because "the '$application' site should have a result" + $siteResult.Accessible | Should -BeTrue -Because "the '$application' site should be accessible after service account change" + $siteResult.StatusCode | Should -Be 200 -Because "the '$application' site should return HTTP 200 after service account change" + } + } + } +} diff --git a/tests/Integration/Commands/Post.UrlReservationRecreate.RS.Integration.Tests.ps1 b/tests/Integration/Commands/Post.UrlReservationRecreate.RS.Integration.Tests.ps1 deleted file mode 100644 index a8f2e41f3..000000000 --- a/tests/Integration/Commands/Post.UrlReservationRecreate.RS.Integration.Tests.ps1 +++ /dev/null @@ -1,111 +0,0 @@ -[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Suppressing this rule because Script Analyzer does not understand Pester syntax.')] -param () - -BeforeDiscovery { - try - { - if (-not (Get-Module -Name 'DscResource.Test')) - { - # Assumes dependencies have been resolved, so if this module is not available, run 'noop' task. - if (-not (Get-Module -Name 'DscResource.Test' -ListAvailable)) - { - # Redirect all streams to $null, except the error stream (stream 2) - & "$PSScriptRoot/../../../build.ps1" -Tasks 'noop' 3>&1 4>&1 5>&1 6>&1 > $null - } - - # If the dependencies have not been resolved, this will throw an error. - Import-Module -Name 'DscResource.Test' -Force -ErrorAction 'Stop' - } - } - catch [System.IO.FileNotFoundException] - { - throw 'DscResource.Test module dependency not found. Please run ".\build.ps1 -ResolveDependency -Tasks noop" first.' - } -} - -BeforeAll { - $script:moduleName = 'SqlServerDsc' - - Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath '../../TestHelpers/CommonTestHelper.psm1') - - # Do not use -Force. Doing so, or unloading the module in AfterAll, causes - # PowerShell class types to get new identities, breaking type comparisons. - Import-Module -Name $script:moduleName -ErrorAction 'Stop' -} - -<# - .NOTES - This test file recreates all URL reservations after the service account - has been changed. URL reservations are tied to the Windows service account - and must be recreated after changing the account to use the new account's - security context. - - This test runs after Post.ServiceAccountChange.RS to ensure the service - account has been verified before recreating URL reservations. -#> -Describe 'Post.UrlReservationRecreate.RS' -Tag @('Integration_SQL2017_RS', 'Integration_SQL2019_RS', 'Integration_SQL2022_RS', 'Integration_PowerBI') { - BeforeAll { - if (Test-ContinuousIntegrationTaskCategory -Category 'Integration_PowerBI') - { - $script:instanceName = 'PBIRS' - } - else - { - # Default to SSRS for SQL2017_RS, SQL2019_RS, SQL2022_RS - $script:instanceName = 'SSRS' - } - - $script:configuration = Get-SqlDscRSConfiguration -InstanceName $script:instanceName -ErrorAction 'Stop' - - # Get URL reservations before recreating - $script:urlReservationsBefore = $script:configuration | Get-SqlDscRSUrlReservation -ErrorAction 'Stop' - - Write-Verbose -Message "Instance: $script:instanceName, URL Reservations before: Application=$($script:urlReservationsBefore.Application -join ',') UrlString=$($script:urlReservationsBefore.UrlString -join ',')" -Verbose - } - - Context 'When recreating URL reservations after service account change' { - It 'Should have URL reservations to recreate' { - $script:urlReservationsBefore | Should -Not -BeNullOrEmpty - $script:urlReservationsBefore.Application | Should -Not -BeNullOrEmpty -Because 'URL reservations should have applications configured' - $script:urlReservationsBefore.UrlString | Should -Not -BeNullOrEmpty -Because 'URL reservations should have URL strings configured' - } - - It 'Should recreate all URL reservations without throwing' { - $null = $script:configuration | Set-SqlDscRSUrlReservation -RecreateExisting -Force -ErrorAction 'Stop' - } - - It 'Should restart the Reporting Services service' { - $null = $script:configuration | Restart-SqlDscRSService -Force -ErrorAction 'Stop' - } - - It 'Should have the same URL reservations after recreating' { - $urlReservationsAfter = $script:configuration | Get-SqlDscRSUrlReservation -ErrorAction 'Stop' - - Write-Verbose -Message "URL Reservations after: Application=$($urlReservationsAfter.Application -join ',') UrlString=$($urlReservationsAfter.UrlString -join ',')" -Verbose - - $urlReservationsAfter.Application.Count | Should -Be $script:urlReservationsBefore.Application.Count -Because 'the number of URL reservations should remain the same' - - # Verify each application and URL combination exists - for ($i = 0; $i -lt $script:urlReservationsBefore.Application.Count; $i++) - { - $expectedApplication = $script:urlReservationsBefore.Application[$i] - $expectedUrl = $script:urlReservationsBefore.UrlString[$i] - - # Find matching reservation in after list - $found = $false - - for ($j = 0; $j -lt $urlReservationsAfter.Application.Count; $j++) - { - if ($urlReservationsAfter.Application[$j] -eq $expectedApplication -and $urlReservationsAfter.UrlString[$j] -eq $expectedUrl) - { - $found = $true - - break - } - } - - $found | Should -BeTrue -Because "URL reservation '$expectedUrl' for application '$expectedApplication' should exist after recreation" - } - } - } -} From 772bf82fea3f699f15a41393e554c8e984b2ea9a Mon Sep 17 00:00:00 2001 From: Johan Ljunggren Date: Sat, 10 Jan 2026 17:16:49 +0100 Subject: [PATCH 02/12] Update database instance name in Reporting Services integration tests --- ...Post.ServiceAccountChange.PowerBI.RS.Integration.Tests.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Integration/Commands/Post.ServiceAccountChange.PowerBI.RS.Integration.Tests.ps1 b/tests/Integration/Commands/Post.ServiceAccountChange.PowerBI.RS.Integration.Tests.ps1 index 65d17c279..79b485e5e 100644 --- a/tests/Integration/Commands/Post.ServiceAccountChange.PowerBI.RS.Integration.Tests.ps1 +++ b/tests/Integration/Commands/Post.ServiceAccountChange.PowerBI.RS.Integration.Tests.ps1 @@ -75,7 +75,7 @@ Describe 'Post.ServiceAccountChange.PowerBI.RS' -Tag @('Integration_PowerBI') { Context 'When granting database rights to the new service account' { BeforeAll { # Connect to the database engine for the RS database instance. - $script:serverObject = Connect-SqlDscDatabaseEngine -ServerName 'localhost' -InstanceName 'PBIRSDB' -ErrorAction 'Stop' + $script:serverObject = Connect-SqlDscDatabaseEngine -ServerName 'localhost' -InstanceName 'RSDB' -ErrorAction 'Stop' } AfterAll { @@ -96,7 +96,7 @@ Describe 'Post.ServiceAccountChange.PowerBI.RS' -Tag @('Integration_PowerBI') { It 'Should execute the database rights script against the database' { $invokeSqlDscQueryParameters = @{ ServerName = 'localhost' - InstanceName = 'PBIRSDB' + InstanceName = 'RSDB' DatabaseName = 'master' Query = $script:databaseRightsScript Force = $true From 3c95a8ec8b805f830ab4d401d931b425b597fff3 Mon Sep 17 00:00:00 2001 From: Johan Ljunggren Date: Sat, 10 Jan 2026 18:45:24 +0100 Subject: [PATCH 03/12] Update expected service account name in Power BI RS integration tests --- .../Post.ServiceAccountChange.PowerBI.RS.Integration.Tests.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Integration/Commands/Post.ServiceAccountChange.PowerBI.RS.Integration.Tests.ps1 b/tests/Integration/Commands/Post.ServiceAccountChange.PowerBI.RS.Integration.Tests.ps1 index 79b485e5e..565798438 100644 --- a/tests/Integration/Commands/Post.ServiceAccountChange.PowerBI.RS.Integration.Tests.ps1 +++ b/tests/Integration/Commands/Post.ServiceAccountChange.PowerBI.RS.Integration.Tests.ps1 @@ -67,7 +67,7 @@ Describe 'Post.ServiceAccountChange.PowerBI.RS' -Tag @('Integration_PowerBI') { $script:databaseName = $script:configuration.DatabaseName $script:computerName = Get-ComputerName - $script:expectedServiceAccount = '{0}\svc-PBIRS' -f $script:computerName + $script:expectedServiceAccount = '{0}\svc-RS' -f $script:computerName Write-Verbose -Message "Instance: $script:instanceName, Database: $script:databaseName, ServiceAccount: $script:serviceAccount" -Verbose } From 77975e03fd5ada3cd539522be80981b485d6d714 Mon Sep 17 00:00:00 2001 From: Johan Ljunggren Date: Sat, 10 Jan 2026 21:42:50 +0100 Subject: [PATCH 04/12] Fix integration test file inclusion for Reporting Services and Power BI --- azure-pipelines.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index c4f436c05..2ab5ed22f 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -594,9 +594,9 @@ stages: # Group 8 'tests/Integration/Commands/Repair-SqlDscReportingService.Integration.Tests.ps1' 'tests/Integration/Commands/Remove-SqlDscRSUrlReservation.Integration.Tests.ps1' - 'tests/Integration/Commands/Remove-SqlDscRSEncryptedInformation.Integration.Tests.ps1' 'tests/Integration/Commands/Remove-SqlDscRSEncryptionKey.Integration.Tests.ps1' 'tests/Integration/Commands/New-SqlDscRSEncryptionKey.Integration.Tests.ps1' + 'tests/Integration/Commands/Remove-SqlDscRSEncryptedInformation.Integration.Tests.ps1' # Group 9 'tests/Integration/Commands/Uninstall-SqlDscReportingService.Integration.Tests.ps1' ) @@ -690,9 +690,9 @@ stages: # Group 8 'tests/Integration/Commands/Repair-SqlDscPowerBIReportServer.Integration.Tests.ps1' 'tests/Integration/Commands/Remove-SqlDscRSUrlReservation.Integration.Tests.ps1' - 'tests/Integration/Commands/Remove-SqlDscRSEncryptedInformation.Integration.Tests.ps1' 'tests/Integration/Commands/Remove-SqlDscRSEncryptionKey.Integration.Tests.ps1' 'tests/Integration/Commands/New-SqlDscRSEncryptionKey.Integration.Tests.ps1' + 'tests/Integration/Commands/Remove-SqlDscRSEncryptedInformation.Integration.Tests.ps1' # Group 9 'tests/Integration/Commands/Uninstall-SqlDscPowerBIReportServer.Integration.Tests.ps1' ) From c8df7d1fda2514f0e8c0b3b8f4438698f33823f8 Mon Sep 17 00:00:00 2001 From: Johan Ljunggren Date: Sun, 11 Jan 2026 10:03:33 +0100 Subject: [PATCH 05/12] Add Timeout parameter to Invoke-RsCimMethod and corresponding tests --- source/Private/Invoke-RsCimMethod.ps1 | 21 +++++++++- .../Unit/Private/Invoke-RsCimMethod.Tests.ps1 | 38 +++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/source/Private/Invoke-RsCimMethod.ps1 b/source/Private/Invoke-RsCimMethod.ps1 index d220dacef..ddb28b53b 100644 --- a/source/Private/Invoke-RsCimMethod.ps1 +++ b/source/Private/Invoke-RsCimMethod.ps1 @@ -17,6 +17,10 @@ .PARAMETER Arguments A hashtable of arguments to pass to the method. + .PARAMETER Timeout + Specifies the timeout in seconds for the CIM operation. If not specified, + the default timeout of the CIM session is used. + .OUTPUTS Microsoft.Management.Infrastructure.CimMethodResult @@ -33,6 +37,12 @@ Invoke-RsCimMethod -CimInstance $config -MethodName 'SetSecureConnectionLevel' -Arguments @{ Level = 1 } Invokes the SetSecureConnectionLevel method with the Level argument. + + .EXAMPLE + $config = Get-SqlDscRSConfiguration -InstanceName 'SSRS' + Invoke-RsCimMethod -CimInstance $config -MethodName 'GenerateDatabaseCreationScript' -Arguments @{ DatabaseName = 'ReportServer' } -Timeout 240 + + Invokes the GenerateDatabaseCreationScript method with a 240 second timeout. #> function Invoke-RsCimMethod { @@ -51,7 +61,11 @@ function Invoke-RsCimMethod [Parameter()] [System.Collections.Hashtable] - $Arguments + $Arguments, + + [Parameter()] + [System.UInt32] + $Timeout ) $invokeCimMethodParameters = @{ @@ -64,6 +78,11 @@ function Invoke-RsCimMethod $invokeCimMethodParameters['Arguments'] = $Arguments } + if ($PSBoundParameters.ContainsKey('Timeout')) + { + $invokeCimMethodParameters['OperationTimeoutSec'] = $Timeout + } + $invokeCimMethodResult = $CimInstance | Invoke-CimMethod @invokeCimMethodParameters <# diff --git a/tests/Unit/Private/Invoke-RsCimMethod.Tests.ps1 b/tests/Unit/Private/Invoke-RsCimMethod.Tests.ps1 index 90f15df7a..15777a2b0 100644 --- a/tests/Unit/Private/Invoke-RsCimMethod.Tests.ps1 +++ b/tests/Unit/Private/Invoke-RsCimMethod.Tests.ps1 @@ -55,6 +55,9 @@ BeforeAll { [System.Collections.Hashtable] $Arguments, + [System.UInt32] + $OperationTimeoutSec, + [System.String] $ErrorAction ) @@ -126,6 +129,41 @@ Describe 'Invoke-RsCimMethod' -Tag 'Private' { $Arguments.Level -eq 1 } -Exactly -Times 1 } + + It 'Should pass timeout to Invoke-CimMethod as OperationTimeoutSec' { + InModuleScope -ScriptBlock { + $mockCimInstance = [PSCustomObject] @{ + InstanceName = 'SSRS' + } + + $result = Invoke-RsCimMethod -CimInstance $mockCimInstance -MethodName 'TestMethod' -Timeout 120 + + $result | Should -Not -BeNullOrEmpty + } + + Should -Invoke -CommandName Invoke-CimMethod -ParameterFilter { + $MethodName -eq 'TestMethod' -and + $OperationTimeoutSec -eq 120 + } -Exactly -Times 1 + } + + It 'Should pass both arguments and timeout to Invoke-CimMethod' { + InModuleScope -ScriptBlock { + $mockCimInstance = [PSCustomObject] @{ + InstanceName = 'SSRS' + } + + $result = Invoke-RsCimMethod -CimInstance $mockCimInstance -MethodName 'GenerateScript' -Arguments @{ DatabaseName = 'ReportServer' } -Timeout 240 + + $result | Should -Not -BeNullOrEmpty + } + + Should -Invoke -CommandName Invoke-CimMethod -ParameterFilter { + $MethodName -eq 'GenerateScript' -and + $Arguments.DatabaseName -eq 'ReportServer' -and + $OperationTimeoutSec -eq 240 + } -Exactly -Times 1 + } } Context 'When CIM method fails with ExtendedErrors' { From bf45d011e21539d10da048057a0b23a1cbf7a9a7 Mon Sep 17 00:00:00 2001 From: Johan Ljunggren Date: Sun, 11 Jan 2026 10:07:08 +0100 Subject: [PATCH 06/12] Add sleep delay in BeforeAll for Remove-SqlDscRSEncryptedInformation tests --- .../Remove-SqlDscRSEncryptedInformation.Integration.Tests.ps1 | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/Integration/Commands/Remove-SqlDscRSEncryptedInformation.Integration.Tests.ps1 b/tests/Integration/Commands/Remove-SqlDscRSEncryptedInformation.Integration.Tests.ps1 index db42f8e80..390771804 100644 --- a/tests/Integration/Commands/Remove-SqlDscRSEncryptedInformation.Integration.Tests.ps1 +++ b/tests/Integration/Commands/Remove-SqlDscRSEncryptedInformation.Integration.Tests.ps1 @@ -39,6 +39,10 @@ BeforeAll { #> Describe 'Remove-SqlDscRSEncryptedInformation' { + BeforeAll { + Start-Sleep -Seconds 300 + } + Context 'When removing encrypted information for SQL Server 2017 Reporting Services' -Tag @('Integration_SQL2017_RS') -Skip:$true { BeforeAll { $script:configuration = Get-SqlDscRSConfiguration -InstanceName 'SSRS' -ErrorAction 'Stop' From b44152226a8636a22b4b94132522bc1397718c50 Mon Sep 17 00:00:00 2001 From: Johan Ljunggren Date: Sun, 11 Jan 2026 10:51:55 +0100 Subject: [PATCH 07/12] Enhance Invoke-RsCimMethod with retry logic and error handling; remove Get-HResultMessage function --- CHANGELOG.md | 9 +- source/Private/Get-HResultMessage.ps1 | 102 ----- source/Private/Invoke-RsCimMethod.ps1 | 148 +++++-- source/en-US/SqlServerDsc.strings.psd1 | 17 +- .../Unit/Private/Get-HResultMessage.Tests.ps1 | 125 ------ .../Unit/Private/Invoke-RsCimMethod.Tests.ps1 | 363 ++++++++++++++++-- 6 files changed, 455 insertions(+), 309 deletions(-) delete mode 100644 source/Private/Get-HResultMessage.ps1 delete mode 100644 tests/Unit/Private/Get-HResultMessage.Tests.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 437604223..f0037225d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -89,10 +89,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Services configuration instances with consistent error handling. This function is used by `Enable-SqlDscRsSecureConnection`, `Disable-SqlDscRsSecureConnection`, and the `SqlRS` resource. -- Added private function `Get-HResultMessage` to translate common Windows HRESULT - error codes into human-readable messages. Used by `Invoke-RsCimMethod` to - provide actionable error messages when Reporting Services CIM methods fail - without detailed error information. + - Added parameters `RetryCount`, `RetryDelaySeconds`, and `SkipRetry` to provide + configurable retry behavior for transient CIM method failures. By default, + retries up to 3 times with 30-second delays. Handles both HRESULT failures + and exceptions from `Invoke-CimMethod`. Collects unique errors across retry + attempts with attempt number prefixes for comprehensive error reporting. - `Invoke-ReportServerSetupAction` - Now uses `Format-Path` with `-ExpandEnvironmentVariable` to expand environment variables in all path parameters (`MediaPath`, `LogPath`, `InstallFolder`) diff --git a/source/Private/Get-HResultMessage.ps1 b/source/Private/Get-HResultMessage.ps1 deleted file mode 100644 index b122f4f1f..000000000 --- a/source/Private/Get-HResultMessage.ps1 +++ /dev/null @@ -1,102 +0,0 @@ -<# - .SYNOPSIS - Gets a human-readable message for a given HRESULT code. - - .DESCRIPTION - Translates common Windows HRESULT error codes into human-readable - messages. This is particularly useful when CIM methods return an - HRESULT code without detailed error information in ExtendedErrors - or Error properties. - - .PARAMETER HResult - The HRESULT code to translate. This is typically a 32-bit signed - integer returned from a Windows API or CIM method call. - - .OUTPUTS - `System.String` - - Returns a descriptive message for known HRESULT codes, or a generic - message with the hexadecimal code for unknown values. - - .EXAMPLE - Get-HResultMessage -HResult -2147023181 - - Returns: The account has not been granted the requested logon type at - this computer. Verify that the service account has the required - permissions to interact with the Reporting Services WMI provider. - - .EXAMPLE - Get-HResultMessage -HResult -2147024891 - - Returns: Access is denied. Verify that the current user has administrator - rights on the Reporting Services instance. - - .NOTES - This function is used internally by other commands to provide actionable - error messages when Reporting Services CIM methods fail without detailed - error information. These codes have not been verified against any official - Microsoft documentation, and based on the common HRESULT values in - https://learn.microsoft.com/en-us/windows/win32/seccrypto/common-hresult-values. -#> -function Get-HResultMessage -{ - [CmdletBinding()] - [OutputType([System.String])] - param - ( - [Parameter(Mandatory = $true)] - [System.Int32] - $HResult - ) - - <# - HRESULT values are 32-bit signed integers. Negative values indicate - errors. The HRESULT is composed of: - - Bit 31: Severity (0 = success, 1 = error) - - Bits 16-30: Facility code - - Bits 0-15: Error code - - Common HRESULT values are documented at: - https://learn.microsoft.com/en-us/windows/win32/seccrypto/common-hresult-values - #> - $hResultMessages = @{ - # cSpell: ignore ACCESSDENIED LOGON - # E_ACCESSDENIED (0x80070005) - General access denied error - -2147024891 = $script:localizedData.HResult_AccessDenied - - # ERROR_LOGON_TYPE_NOT_GRANTED (0x80070533) - Account lacks logon rights - -2147023181 = $script:localizedData.HResult_LogonTypeNotGranted - - # E_FAIL (0x80004005) - Unspecified failure - -2147467259 = $script:localizedData.HResult_UnspecifiedFailure - - # E_INVALIDARG (0x80070057) - One or more arguments are invalid - -2147024809 = $script:localizedData.HResult_InvalidArgument - - # E_OUTOFMEMORY (0x8007000E) - Out of memory - -2147024882 = $script:localizedData.HResult_OutOfMemory - - # RPC_E_DISCONNECTED (0x80010108) - The object invoked has disconnected - -2147417848 = $script:localizedData.HResult_RpcDisconnected - - # RPC_S_SERVER_UNAVAILABLE (0x800706BA) - The RPC server is unavailable - -2147023174 = $script:localizedData.HResult_RpcServerUnavailable - - # ERROR_SERVICE_NOT_ACTIVE (0x80070426) - The service has not been started - -2147023834 = $script:localizedData.HResult_ServiceNotActive - } - - if ($hResultMessages.ContainsKey($HResult)) - { - return $hResultMessages[$HResult] - } - - <# - Return a generic message with the hexadecimal representation for unknown codes. - Convert to hex using bitwise operation to handle negative values that would - overflow when casting directly to UInt32 (e.g., Int32.MinValue = -2147483648). - #> - $hexValue = '0x{0:X8}' -f ($HResult -band 0xFFFFFFFF) - - return ($script:localizedData.HResult_Unknown -f $hexValue) -} diff --git a/source/Private/Invoke-RsCimMethod.ps1 b/source/Private/Invoke-RsCimMethod.ps1 index ddb28b53b..a52fea126 100644 --- a/source/Private/Invoke-RsCimMethod.ps1 +++ b/source/Private/Invoke-RsCimMethod.ps1 @@ -8,6 +8,12 @@ handles both ExtendedErrors and Error properties that can be returned by the CIM method. + By default, the function retries failed method calls (HRESULT failures) + up to 2 times with a 30-second delay between attempts. This behavior + can be customized using the RetryCount and RetryDelaySeconds parameters, + or disabled entirely using the SkipRetry switch. Exceptions thrown by + Invoke-CimMethod are not retried and will immediately terminate. + .PARAMETER CimInstance The CIM instance object that contains the method to call. @@ -21,6 +27,18 @@ Specifies the timeout in seconds for the CIM operation. If not specified, the default timeout of the CIM session is used. + .PARAMETER RetryCount + Specifies the number of retry attempts after the initial failure. The + default is 2. Set to 0 to disable retries (equivalent to SkipRetry). + + .PARAMETER RetryDelaySeconds + Specifies the number of seconds to wait between retry attempts. The + default is 30. + + .PARAMETER SkipRetry + When specified, disables retry behavior entirely. The method will only + be attempted once. + .OUTPUTS Microsoft.Management.Infrastructure.CimMethodResult @@ -43,11 +61,23 @@ Invoke-RsCimMethod -CimInstance $config -MethodName 'GenerateDatabaseCreationScript' -Arguments @{ DatabaseName = 'ReportServer' } -Timeout 240 Invokes the GenerateDatabaseCreationScript method with a 240 second timeout. + + .EXAMPLE + $config = Get-SqlDscRSConfiguration -InstanceName 'SSRS' + Invoke-RsCimMethod -CimInstance $config -MethodName 'SetDatabaseConnection' -RetryCount 5 -RetryDelaySeconds 60 + + Invokes the SetDatabaseConnection method with up to 5 retries and a 60 second + delay between attempts. + + .EXAMPLE + $config = Get-SqlDscRSConfiguration -InstanceName 'SSRS' + Invoke-RsCimMethod -CimInstance $config -MethodName 'ListReservedUrls' -SkipRetry + + Invokes the ListReservedUrls method without any retry behavior. #> function Invoke-RsCimMethod { - [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('AvoidThrowOutsideOfTry', '', Justification = 'Because the code throws based on an prior expression')] - [CmdletBinding()] + [CmdletBinding(DefaultParameterSetName = 'Retry')] [OutputType([Microsoft.Management.Infrastructure.CimMethodResult])] param ( @@ -65,7 +95,19 @@ function Invoke-RsCimMethod [Parameter()] [System.UInt32] - $Timeout + $Timeout, + + [Parameter(ParameterSetName = 'Retry')] + [System.UInt32] + $RetryCount = 2, + + [Parameter(ParameterSetName = 'Retry')] + [System.UInt32] + $RetryDelaySeconds = 30, + + [Parameter(ParameterSetName = 'NoRetry')] + [System.Management.Automation.SwitchParameter] + $SkipRetry ) $invokeCimMethodParameters = @{ @@ -83,48 +125,98 @@ function Invoke-RsCimMethod $invokeCimMethodParameters['OperationTimeoutSec'] = $Timeout } - $invokeCimMethodResult = $CimInstance | Invoke-CimMethod @invokeCimMethodParameters + # Calculate total attempts (1 initial + RetryCount retries, unless SkipRetry is specified). + $maxAttempts = if ($SkipRetry.IsPresent) + { + 1 + } + else + { + 1 + $RetryCount + } - <# - Successfully calling the method returns $invokeCimMethodResult.HRESULT -eq 0. - If a general error occurs in the Invoke-CimMethod, like calling a method - that does not exist, returns $null in $invokeCimMethodResult. + # Track unique errors across attempts to provide comprehensive error information. + $collectedErrors = [System.Collections.Generic.List[System.String]]::new() + $uniqueErrorKeys = [System.Collections.Generic.HashSet[System.String]]::new() - cSpell: ignore HRESULT - #> - if ($invokeCimMethodResult -and $invokeCimMethodResult.HRESULT -ne 0) + # cSpell: ignore HRESULT + for ($attemptNumber = 1; $attemptNumber -le $maxAttempts; $attemptNumber++) { - $errorDetails = $null + $invokeCimMethodResult = $CimInstance | Invoke-CimMethod @invokeCimMethodParameters <# - The returned object property ExtendedErrors is an array - so that needs to be concatenated. Check if it has actual - content before using it. + Successfully calling the method returns $invokeCimMethodResult.HRESULT -eq 0. + If a general error occurs in the Invoke-CimMethod, like calling a method + that does not exist, returns $null in $invokeCimMethodResult or throws + an exception (which will terminate immediately without retry). #> - if (($invokeCimMethodResult | Get-Member -Name 'ExtendedErrors') -and $invokeCimMethodResult.ExtendedErrors) + $isSuccess = $null -ne $invokeCimMethodResult -and $invokeCimMethodResult.HRESULT -eq 0 + + if ($isSuccess) { - $errorDetails = $invokeCimMethodResult.ExtendedErrors -join ';' + return $invokeCimMethodResult } - # Fall back to Error property if ExtendedErrors was empty or not present. - if (-not $errorDetails -and ($invokeCimMethodResult | Get-Member -Name 'Error') -and $invokeCimMethodResult.Error) + # Build error details for this attempt. + $errorDetails = $null + $errorKey = $null + + if ($null -ne $invokeCimMethodResult -and $invokeCimMethodResult.HRESULT -ne 0) { - $errorDetails = $invokeCimMethodResult.Error + # Handle HRESULT failure. + $hResult = $invokeCimMethodResult.HRESULT + $methodErrorDetails = $null + + <# + The returned object property ExtendedErrors is an array + so that needs to be concatenated. Check if it has actual + content before using it. + #> + if (($invokeCimMethodResult | Get-Member -Name 'ExtendedErrors') -and $invokeCimMethodResult.ExtendedErrors) + { + $methodErrorDetails = $invokeCimMethodResult.ExtendedErrors -join ';' + } + + # Fall back to Error property if ExtendedErrors was empty or not present. + if (-not $methodErrorDetails -and ($invokeCimMethodResult | Get-Member -Name 'Error') -and $invokeCimMethodResult.Error) + { + $methodErrorDetails = $invokeCimMethodResult.Error + } + + # Use a fallback message if neither property had content. + if (-not $methodErrorDetails) + { + $methodErrorDetails = $script:localizedData.Invoke_RsCimMethod_NoErrorDetails + } + + $errorDetails = $script:localizedData.Invoke_RsCimMethod_HResultError -f $hResult, $methodErrorDetails + $errorKey = "HRESULT:$hResult`:$methodErrorDetails" } - # Use a fallback message if neither property had content. - if (-not $errorDetails) + # Track unique errors with attempt number prefix. + if ($errorDetails -and -not $uniqueErrorKeys.Contains($errorKey)) { - $errorDetails = $script:localizedData.Invoke_RsCimMethod_NoErrorDetails + $null = $uniqueErrorKeys.Add($errorKey) + + $attemptError = $script:localizedData.Invoke_RsCimMethod_AttemptError -f $attemptNumber, $errorDetails + $collectedErrors.Add($attemptError) } - # Try to translate the HRESULT code into a human-readable message. - $hResultMessage = Get-HResultMessage -HResult $invokeCimMethodResult.HRESULT + Write-Debug -Message ($script:localizedData.Invoke_RsCimMethod_AttemptFailed -f $attemptNumber, $errorDetails) - $errorMessage = $script:localizedData.Invoke_RsCimMethod_FailedToInvokeMethod -f $MethodName, $errorDetails, $hResultMessage, $invokeCimMethodResult.HRESULT + # If there are more attempts, wait before retrying. + if ($attemptNumber -lt $maxAttempts) + { + Write-Debug -Message ($script:localizedData.Invoke_RsCimMethod_WaitingBeforeRetry -f $RetryDelaySeconds, ($attemptNumber + 1)) - throw $errorMessage + Start-Sleep -Seconds $RetryDelaySeconds + } } - return $invokeCimMethodResult + # All attempts failed, throw with collected unique errors. + $allErrors = $collectedErrors -join ' ' + + $errorMessage = $script:localizedData.Invoke_RsCimMethod_FailedToInvokeMethod -f $MethodName, $allErrors + + Write-Error -Message $errorMessage -Category 'InvalidResult' -ErrorId 'IRCM0001' -TargetObject $MethodName -ErrorAction 'Stop' } diff --git a/source/en-US/SqlServerDsc.strings.psd1 b/source/en-US/SqlServerDsc.strings.psd1 index 88cdfdc64..7338fb38b 100644 --- a/source/en-US/SqlServerDsc.strings.psd1 +++ b/source/en-US/SqlServerDsc.strings.psd1 @@ -344,19 +344,12 @@ ConvertFrom-StringData @' Disable_SqlDscRsSecureConnection_FailedToDisable = Failed to disable secure connection for Reporting Services instance '{0}'. {1} (DSRSSC0001) ## Invoke-RsCimMethod - Invoke_RsCimMethod_FailedToInvokeMethod = Method {0}() failed with an error. Error: {1} {2} (HRESULT:{3}) (IRCM0001) + Invoke_RsCimMethod_FailedToInvokeMethod = Method {0}() failed after all attempts. Errors: {1} (IRCM0001) Invoke_RsCimMethod_NoErrorDetails = No error details were returned by the method. See HRESULT code for more information. (IRCM0002) - - ## Get-HResultMessage - HResult_AccessDenied = Access is denied. Verify that the current user has administrator rights on the Reporting Services instance. (GHRM0001) - HResult_LogonTypeNotGranted = The account has not been granted the requested logon type at this computer. Verify that the Reporting Services service is running and that the service account has the required permissions to interact with the Reporting Services WMI provider. (GHRM0002) - HResult_UnspecifiedFailure = An unspecified failure occurred. (GHRM0003) - HResult_InvalidArgument = One or more arguments are not valid. (GHRM0004) - HResult_OutOfMemory = The system is out of memory. (GHRM0005) - HResult_RpcDisconnected = The object invoked has disconnected from its clients or the RPC connection was lost. Verify that the Reporting Services service is running. (GHRM0006) - HResult_RpcServerUnavailable = The RPC server is unavailable. Verify that the Reporting Services service is running and accessible. (GHRM0007) - HResult_ServiceNotActive = The service has not been started. Verify that the Reporting Services service is running. (GHRM0008) - HResult_Unknown = Unknown HRESULT code {0}. Refer to Microsoft documentation for more information. (GHRM0009) + Invoke_RsCimMethod_HResultError = HRESULT: {0}, Error: {1} (IRCM0003) + Invoke_RsCimMethod_AttemptError = Attempt {0}: {1} (IRCM0004) + Invoke_RsCimMethod_AttemptFailed = Attempt {0} failed. {1} (IRCM0005) + Invoke_RsCimMethod_WaitingBeforeRetry = Waiting {0} seconds before retry attempt {1}. (IRCM0006) ## Test-SqlDscRSInstalled Test_SqlDscRSInstalled_Checking = Checking if Reporting Services instance '{0}' is installed. diff --git a/tests/Unit/Private/Get-HResultMessage.Tests.ps1 b/tests/Unit/Private/Get-HResultMessage.Tests.ps1 deleted file mode 100644 index c659b75e9..000000000 --- a/tests/Unit/Private/Get-HResultMessage.Tests.ps1 +++ /dev/null @@ -1,125 +0,0 @@ -[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Suppressing this rule because Script Analyzer does not understand Pester syntax.')] -param () - -BeforeDiscovery { - try - { - if (-not (Get-Module -Name 'DscResource.Test')) - { - # Assumes dependencies have been resolved, so if this module is not available, run 'noop' task. - if (-not (Get-Module -Name 'DscResource.Test' -ListAvailable)) - { - # Redirect all streams to $null, except the error stream (stream 2) - & "$PSScriptRoot/../../../build.ps1" -Tasks 'noop' 3>&1 4>&1 5>&1 6>&1 > $null - } - - # If the dependencies have not been resolved, this will throw an error. - Import-Module -Name 'DscResource.Test' -Force -ErrorAction 'Stop' - } - } - catch [System.IO.FileNotFoundException] - { - throw 'DscResource.Test module dependency not found. Please run ".\build.ps1 -ResolveDependency -Tasks noop" first.' - } -} - -BeforeAll { - $script:moduleName = 'SqlServerDsc' - - # Do not use -Force. Doing so, or unloading the module in AfterAll, causes - # PowerShell class types to get new identities, breaking type comparisons. - Import-Module -Name $script:moduleName -ErrorAction 'Stop' - - $PSDefaultParameterValues['InModuleScope:ModuleName'] = $script:moduleName - $PSDefaultParameterValues['Mock:ModuleName'] = $script:moduleName - $PSDefaultParameterValues['Should:ModuleName'] = $script:moduleName - - $env:SqlServerDscCI = $true -} - -AfterAll { - Remove-Item -Path 'env:SqlServerDscCI' -ErrorAction 'SilentlyContinue' - - $PSDefaultParameterValues.Remove('InModuleScope:ModuleName') - $PSDefaultParameterValues.Remove('Mock:ModuleName') - $PSDefaultParameterValues.Remove('Should:ModuleName') -} - -Describe 'Get-HResultMessage' -Tag 'Private' { - Context 'When translating known HRESULT codes' { - BeforeDiscovery { - $testCases = @( - @{ - HResult = -2147024891 - ExpectedPattern = '*Access is denied*' - Description = 'E_ACCESSDENIED' - } - @{ - HResult = -2147023181 - ExpectedPattern = '*logon type*' - Description = 'ERROR_LOGON_TYPE_NOT_GRANTED' - } - @{ - HResult = -2147467259 - ExpectedPattern = '*unspecified failure*' - Description = 'E_FAIL' - } - @{ - HResult = -2147024809 - ExpectedPattern = '*arguments are not valid*' - Description = 'E_INVALIDARG' - } - @{ - HResult = -2147024882 - ExpectedPattern = '*out of memory*' - Description = 'E_OUTOFMEMORY' - } - @{ - HResult = -2147417848 - ExpectedPattern = '*disconnected*' - Description = 'RPC_E_DISCONNECTED' - } - @{ - HResult = -2147023174 - ExpectedPattern = '*RPC server is unavailable*' - Description = 'RPC_S_SERVER_UNAVAILABLE' - } - @{ - HResult = -2147023834 - ExpectedPattern = '*service has not been started*' - Description = 'ERROR_SERVICE_NOT_ACTIVE' - } - ) - } - - It 'Should return a descriptive message for (HRESULT: )' -ForEach $testCases { - InModuleScope -Parameters $_ -ScriptBlock { - $result = Get-HResultMessage -HResult $HResult - - $result | Should -BeLike $ExpectedPattern - } - } - } - - Context 'When translating an unknown HRESULT code' { - It 'Should return a generic message with the hexadecimal code' { - InModuleScope -ScriptBlock { - $result = Get-HResultMessage -HResult -2147483648 - - $result | Should -BeLike '*Unknown HRESULT*' - $result | Should -BeLike '*0x80000000*' - } - } - } - - Context 'When translating a positive HRESULT code' { - It 'Should return a generic message for positive values' { - InModuleScope -ScriptBlock { - # Positive values are typically success codes but if they don't map, return unknown - $result = Get-HResultMessage -HResult 12345 - - $result | Should -BeLike '*Unknown HRESULT*' - } - } - } -} diff --git a/tests/Unit/Private/Invoke-RsCimMethod.Tests.ps1 b/tests/Unit/Private/Invoke-RsCimMethod.Tests.ps1 index 15777a2b0..9554220c9 100644 --- a/tests/Unit/Private/Invoke-RsCimMethod.Tests.ps1 +++ b/tests/Unit/Private/Invoke-RsCimMethod.Tests.ps1 @@ -75,7 +75,7 @@ BeforeAll { } AfterAll { - $env:SqlServerDscCI = $null + Remove-Item -Path 'env:SqlServerDscCI' -ErrorAction 'SilentlyContinue' InModuleScope -ScriptBlock { Remove-Item -Path 'function:script:Invoke-CimMethod' -Force -ErrorAction SilentlyContinue @@ -87,13 +87,15 @@ AfterAll { } Describe 'Invoke-RsCimMethod' -Tag 'Private' { - Context 'When invoking a CIM method successfully' { + Context 'When invoking a CIM method successfully on first attempt' { BeforeAll { Mock -CommandName Invoke-CimMethod -MockWith { return [PSCustomObject] @{ HRESULT = 0 } } + + Mock -CommandName Start-Sleep } It 'Should invoke the method without errors' { @@ -111,6 +113,8 @@ Describe 'Invoke-RsCimMethod' -Tag 'Private' { Should -Invoke -CommandName Invoke-CimMethod -ParameterFilter { $MethodName -eq 'TestMethod' } -Exactly -Times 1 + + Should -Invoke -CommandName Start-Sleep -Exactly -Times 0 } It 'Should pass arguments to the CIM method' { @@ -166,74 +170,142 @@ Describe 'Invoke-RsCimMethod' -Tag 'Private' { } } - Context 'When CIM method fails with ExtendedErrors' { + Context 'When CIM method returns a result with properties' { BeforeAll { Mock -CommandName Invoke-CimMethod -MockWith { - $result = [PSCustomObject] @{ - HRESULT = 1 + return [PSCustomObject] @{ + HRESULT = 0 + Application = @('ReportServerWebService', 'ReportServerWebApp') + UrlString = @('http://+:80/ReportServer', 'http://+:80/Reports') } - $result | Add-Member -MemberType NoteProperty -Name 'ExtendedErrors' -Value @('Extended error message') - return $result } + + Mock -CommandName Start-Sleep } - It 'Should throw with extended error message' { + It 'Should return the full result object' { InModuleScope -ScriptBlock { $mockCimInstance = [PSCustomObject] @{ InstanceName = 'SSRS' } - { Invoke-RsCimMethod -CimInstance $mockCimInstance -MethodName 'TestMethod' } | - Should -Throw -ExpectedMessage '*TestMethod*Extended error message*HRESULT:1*' + $result = Invoke-RsCimMethod -CimInstance $mockCimInstance -MethodName 'ListReservedUrls' + + $result | Should -Not -BeNullOrEmpty + $result.Application | Should -HaveCount 2 + $result.Application[0] | Should -Be 'ReportServerWebService' + $result.UrlString[0] | Should -Be 'http://+:80/ReportServer' } } } - Context 'When CIM method fails with Error property' { + Context 'When CIM method succeeds after retry' { BeforeAll { + $script:invokeCimMethodCallCount = 0 + Mock -CommandName Invoke-CimMethod -MockWith { + $script:invokeCimMethodCallCount++ + + if ($script:invokeCimMethodCallCount -lt 3) + { + return [PSCustomObject] @{ + HRESULT = 1 + Error = 'Temporary failure' + } + } + return [PSCustomObject] @{ - HRESULT = 2 - Error = 'Error property message' + HRESULT = 0 + } + } + + Mock -CommandName Start-Sleep + } + + BeforeEach { + $script:invokeCimMethodCallCount = 0 + } + + It 'Should succeed after retrying' { + InModuleScope -ScriptBlock { + $mockCimInstance = [PSCustomObject] @{ + InstanceName = 'SSRS' + } + + $result = Invoke-RsCimMethod -CimInstance $mockCimInstance -MethodName 'TestMethod' + + $result | Should -Not -BeNullOrEmpty + $result.HRESULT | Should -Be 0 + } + + Should -Invoke -CommandName Invoke-CimMethod -Exactly -Times 3 + Should -Invoke -CommandName Start-Sleep -Exactly -Times 2 + } + + It 'Should use default delay of 30 seconds between retries' { + InModuleScope -ScriptBlock { + $mockCimInstance = [PSCustomObject] @{ + InstanceName = 'SSRS' + } + + $null = Invoke-RsCimMethod -CimInstance $mockCimInstance -MethodName 'TestMethod' + } + + Should -Invoke -CommandName Start-Sleep -ParameterFilter { + $Seconds -eq 30 + } -Exactly -Times 2 + } + } + + Context 'When CIM method fails with ExtendedErrors and all retries exhausted' { + BeforeAll { + Mock -CommandName Invoke-CimMethod -MockWith { + $result = [PSCustomObject] @{ + HRESULT = 1 } + $result | Add-Member -MemberType NoteProperty -Name 'ExtendedErrors' -Value @('Extended error message') + return $result } + + Mock -CommandName Start-Sleep } - It 'Should throw with error property message' { + It 'Should throw with extended error message after all retries' { InModuleScope -ScriptBlock { $mockCimInstance = [PSCustomObject] @{ InstanceName = 'SSRS' } { Invoke-RsCimMethod -CimInstance $mockCimInstance -MethodName 'TestMethod' } | - Should -Throw -ExpectedMessage '*TestMethod*Error property message*HRESULT:2*' + Should -Throw -ExpectedMessage '*TestMethod*HRESULT: 1*Extended error message*' } + + # 1 initial + 3 retries = 4 attempts + Should -Invoke -CommandName Invoke-CimMethod -Exactly -Times 3 + Should -Invoke -CommandName Start-Sleep -Exactly -Times 2 } } - Context 'When CIM method returns a result with properties' { + Context 'When CIM method fails with Error property and all retries exhausted' { BeforeAll { Mock -CommandName Invoke-CimMethod -MockWith { return [PSCustomObject] @{ - HRESULT = 0 - Application = @('ReportServerWebService', 'ReportServerWebApp') - UrlString = @('http://+:80/ReportServer', 'http://+:80/Reports') + HRESULT = 2 + Error = 'Error property message' } } + + Mock -CommandName Start-Sleep } - It 'Should return the full result object' { + It 'Should throw with error property message after all retries' { InModuleScope -ScriptBlock { $mockCimInstance = [PSCustomObject] @{ InstanceName = 'SSRS' } - $result = Invoke-RsCimMethod -CimInstance $mockCimInstance -MethodName 'ListReservedUrls' - - $result | Should -Not -BeNullOrEmpty - $result.Application | Should -HaveCount 2 - $result.Application[0] | Should -Be 'ReportServerWebService' - $result.UrlString[0] | Should -Be 'http://+:80/ReportServer' + { Invoke-RsCimMethod -CimInstance $mockCimInstance -MethodName 'TestMethod' } | + Should -Throw -ExpectedMessage '*TestMethod*HRESULT: 2*Error property message*' } } } @@ -248,6 +320,8 @@ Describe 'Invoke-RsCimMethod' -Tag 'Private' { $result | Add-Member -MemberType NoteProperty -Name 'ExtendedErrors' -Value @() return $result } + + Mock -CommandName Start-Sleep } It 'Should fall back to Error property message' { @@ -257,7 +331,7 @@ Describe 'Invoke-RsCimMethod' -Tag 'Private' { } { Invoke-RsCimMethod -CimInstance $mockCimInstance -MethodName 'TestMethod' } | - Should -Throw -ExpectedMessage '*TestMethod*Fallback error message*HRESULT:3*' + Should -Throw -ExpectedMessage '*TestMethod*HRESULT: 3*Fallback error message*' } } } @@ -272,6 +346,8 @@ Describe 'Invoke-RsCimMethod' -Tag 'Private' { $result | Add-Member -MemberType NoteProperty -Name 'ExtendedErrors' -Value @() return $result } + + Mock -CommandName Start-Sleep } It 'Should use fallback message when neither ExtendedErrors nor Error have content' { @@ -281,32 +357,243 @@ Describe 'Invoke-RsCimMethod' -Tag 'Private' { } { Invoke-RsCimMethod -CimInstance $mockCimInstance -MethodName 'TestMethod' } | - Should -Throw -ExpectedMessage '*TestMethod*No error details were returned*Unknown HRESULT*HRESULT:4*' + Should -Throw -ExpectedMessage '*TestMethod*HRESULT: 4*No error details were returned*' } } } - Context 'When CIM method fails with a known HRESULT code' { + Context 'When Invoke-CimMethod throws an exception' { BeforeAll { Mock -CommandName Invoke-CimMethod -MockWith { - $result = [PSCustomObject] @{ - # ERROR_LOGON_TYPE_NOT_GRANTED (0x80070533) - HRESULT = -2147023181 - Error = '' - } - $result | Add-Member -MemberType NoteProperty -Name 'ExtendedErrors' -Value @() - return $result + throw [System.InvalidOperationException]::new('Connection failure') } + + Mock -CommandName Start-Sleep } - It 'Should include the translated HRESULT message in the error' { + It 'Should throw immediately without retrying' { InModuleScope -ScriptBlock { $mockCimInstance = [PSCustomObject] @{ InstanceName = 'SSRS' } { Invoke-RsCimMethod -CimInstance $mockCimInstance -MethodName 'TestMethod' } | - Should -Throw -ExpectedMessage '*logon type*HRESULT:-2147023181*' + Should -Throw -ExpectedMessage '*Connection failure*' + } + + # Only 1 attempt - exceptions are not retried + Should -Invoke -CommandName Invoke-CimMethod -Exactly -Times 1 + Should -Invoke -CommandName Start-Sleep -Exactly -Times 0 + } + } + + Context 'When SkipRetry is specified' { + BeforeAll { + Mock -CommandName Invoke-CimMethod -MockWith { + return [PSCustomObject] @{ + HRESULT = 1 + Error = 'Single attempt failure' + } + } + + Mock -CommandName Start-Sleep + } + + It 'Should only attempt once and not retry' { + InModuleScope -ScriptBlock { + $mockCimInstance = [PSCustomObject] @{ + InstanceName = 'SSRS' + } + + { Invoke-RsCimMethod -CimInstance $mockCimInstance -MethodName 'TestMethod' -SkipRetry } | + Should -Throw -ExpectedMessage '*TestMethod*Single attempt failure*' + } + + Should -Invoke -CommandName Invoke-CimMethod -Exactly -Times 1 + Should -Invoke -CommandName Start-Sleep -Exactly -Times 0 + } + } + + Context 'When custom RetryCount and RetryDelaySeconds are specified' { + BeforeAll { + Mock -CommandName Invoke-CimMethod -MockWith { + return [PSCustomObject] @{ + HRESULT = 1 + Error = 'Failure' + } + } + + Mock -CommandName Start-Sleep + } + + It 'Should use custom retry count' { + InModuleScope -ScriptBlock { + $mockCimInstance = [PSCustomObject] @{ + InstanceName = 'SSRS' + } + + { Invoke-RsCimMethod -CimInstance $mockCimInstance -MethodName 'TestMethod' -RetryCount 5 } | + Should -Throw + } + + # 1 initial + 5 retries = 6 attempts + Should -Invoke -CommandName Invoke-CimMethod -Exactly -Times 6 + Should -Invoke -CommandName Start-Sleep -Exactly -Times 5 + } + + It 'Should use custom delay between retries' { + InModuleScope -ScriptBlock { + $mockCimInstance = [PSCustomObject] @{ + InstanceName = 'SSRS' + } + + { Invoke-RsCimMethod -CimInstance $mockCimInstance -MethodName 'TestMethod' -RetryDelaySeconds 60 -RetryCount 2 } | + Should -Throw + } + + # 1 initial + 2 retries = 3 attempts with 2 sleeps + Should -Invoke -CommandName Start-Sleep -ParameterFilter { + $Seconds -eq 60 + } -Exactly -Times 2 + } + } + + Context 'When RetryCount is 0' { + BeforeAll { + Mock -CommandName Invoke-CimMethod -MockWith { + return [PSCustomObject] @{ + HRESULT = 1 + Error = 'No retry failure' + } + } + + Mock -CommandName Start-Sleep + } + + It 'Should only attempt once like SkipRetry' { + InModuleScope -ScriptBlock { + $mockCimInstance = [PSCustomObject] @{ + InstanceName = 'SSRS' + } + + { Invoke-RsCimMethod -CimInstance $mockCimInstance -MethodName 'TestMethod' -RetryCount 0 } | + Should -Throw -ExpectedMessage '*TestMethod*No retry failure*' + } + + Should -Invoke -CommandName Invoke-CimMethod -Exactly -Times 1 + Should -Invoke -CommandName Start-Sleep -Exactly -Times 0 + } + } + + Context 'When different errors occur across retries' { + BeforeAll { + $script:invokeCimMethodCallCount = 0 + + Mock -CommandName Invoke-CimMethod -MockWith { + $script:invokeCimMethodCallCount++ + + switch ($script:invokeCimMethodCallCount) + { + 1 + { + return [PSCustomObject] @{ + HRESULT = 1 + Error = 'First error' + } + } + 2 + { + return [PSCustomObject] @{ + HRESULT = 2 + Error = 'Second error' + } + } + 3 + { + return [PSCustomObject] @{ + HRESULT = 3 + Error = 'Third error' + } + } + default + { + return [PSCustomObject] @{ + HRESULT = 4 + Error = 'Fourth error' + } + } + } + } + + Mock -CommandName Start-Sleep + } + + BeforeEach { + $script:invokeCimMethodCallCount = 0 + } + + It 'Should collect all unique errors in the final error message' { + InModuleScope -ScriptBlock { + $mockCimInstance = [PSCustomObject] @{ + InstanceName = 'SSRS' + } + + $errorThrown = $null + + try + { + # Use RetryCount 3 to get 4 total attempts (1 initial + 3 retries) + Invoke-RsCimMethod -CimInstance $mockCimInstance -MethodName 'TestMethod' -RetryCount 3 + } + catch + { + $errorThrown = $_.Exception.Message + } + + $errorThrown | Should -Not -BeNullOrEmpty + $errorThrown | Should -BeLike '*Attempt 1:*First error*' + $errorThrown | Should -BeLike '*Attempt 2:*Second error*' + $errorThrown | Should -BeLike '*Attempt 3:*Third error*' + $errorThrown | Should -BeLike '*Attempt 4:*Fourth error*' + } + } + } + + Context 'When same error repeats across retries' { + BeforeAll { + Mock -CommandName Invoke-CimMethod -MockWith { + return [PSCustomObject] @{ + HRESULT = 1 + Error = 'Same error' + } + } + + Mock -CommandName Start-Sleep + } + + It 'Should only include unique error once in the final error message' { + InModuleScope -ScriptBlock { + $mockCimInstance = [PSCustomObject] @{ + InstanceName = 'SSRS' + } + + $errorThrown = $null + + try + { + Invoke-RsCimMethod -CimInstance $mockCimInstance -MethodName 'TestMethod' + } + catch + { + $errorThrown = $_.Exception.Message + } + + $errorThrown | Should -Not -BeNullOrEmpty + + # With default RetryCount=2, we have 3 attempts but same error should only appear once + $errorThrown | Should -BeLike '*Attempt 1:*Same error*' + $errorThrown | Should -Not -BeLike '*Attempt 2:*' + $errorThrown | Should -Not -BeLike '*Attempt 3:*' } } } From fffc4c553105a617bd4b10fe4a6e6b17817812d5 Mon Sep 17 00:00:00 2001 From: Johan Ljunggren Date: Sun, 11 Jan 2026 11:34:46 +0100 Subject: [PATCH 08/12] Disable 'AvoidWriteErrorStop' rule due to conflicts with 'Write-Error -ErrorAction Stop' usage; reference ongoing investigation in PR #2364 --- .vscode/analyzersettings.psd1 | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.vscode/analyzersettings.psd1 b/.vscode/analyzersettings.psd1 index 14c8e51b5..a8bca9505 100644 --- a/.vscode/analyzersettings.psd1 +++ b/.vscode/analyzersettings.psd1 @@ -66,7 +66,13 @@ 'AvoidProcessWithoutPipeline' 'AvoidSmartQuotes' 'AvoidThrowOutsideOfTry' - 'AvoidWriteErrorStop' + <# + 'AvoidWriteErrorStop' rule is disabled because it conflicts with + the use of 'Write-Error -ErrorAction Stop' pattern used in the module. + There are edge case issues with $PSCmdlet.ThrowTerminatingError that + being investigated in Pull Request https://github.com/dsccommunity/SqlServerDsc/pull/2364. + #> + #'AvoidWriteErrorStop' 'AvoidWriteOutput' 'UseSyntacticallyCorrectExamples' ) From 5fe628330433b4df849bbfba7cde154a4549266b Mon Sep 17 00:00:00 2001 From: Johan Ljunggren Date: Sun, 11 Jan 2026 12:34:59 +0100 Subject: [PATCH 09/12] Refactor integration tests to suppress output from Invoke-SqlDscQuery and Initialize-SqlDscRS; remove unnecessary sleep in Remove-SqlDscRSEncryptedInformation tests; update retry logic comment in Invoke-RsCimMethod tests --- ...st.ServiceAccountChange.PowerBI.RS.Integration.Tests.ps1 | 4 ++-- ...st.ServiceAccountChange.SQL2017.RS.Integration.Tests.ps1 | 6 +++--- ...rviceAccountChange.SQL2019-2022.RS.Integration.Tests.ps1 | 4 ++-- ...emove-SqlDscRSEncryptedInformation.Integration.Tests.ps1 | 4 ---- tests/Unit/Private/Invoke-RsCimMethod.Tests.ps1 | 2 +- 5 files changed, 8 insertions(+), 12 deletions(-) diff --git a/tests/Integration/Commands/Post.ServiceAccountChange.PowerBI.RS.Integration.Tests.ps1 b/tests/Integration/Commands/Post.ServiceAccountChange.PowerBI.RS.Integration.Tests.ps1 index 565798438..c7b15bf97 100644 --- a/tests/Integration/Commands/Post.ServiceAccountChange.PowerBI.RS.Integration.Tests.ps1 +++ b/tests/Integration/Commands/Post.ServiceAccountChange.PowerBI.RS.Integration.Tests.ps1 @@ -103,7 +103,7 @@ Describe 'Post.ServiceAccountChange.PowerBI.RS' -Tag @('Integration_PowerBI') { ErrorAction = 'Stop' } - Invoke-SqlDscQuery @invokeSqlDscQueryParameters + $null = Invoke-SqlDscQuery @invokeSqlDscQueryParameters } It 'Should restart the Reporting Services service after granting rights' { @@ -141,7 +141,7 @@ Describe 'Post.ServiceAccountChange.PowerBI.RS' -Tag @('Integration_PowerBI') { # Refresh configuration $script:configuration = Get-SqlDscRSConfiguration -InstanceName $script:instanceName -ErrorAction 'Stop' - $script:configuration | Initialize-SqlDscRS -Force -ErrorAction 'Stop' + $null = $script:configuration | Initialize-SqlDscRS -Force -ErrorAction 'Stop' } It 'Should have an initialized instance after re-initialization' { diff --git a/tests/Integration/Commands/Post.ServiceAccountChange.SQL2017.RS.Integration.Tests.ps1 b/tests/Integration/Commands/Post.ServiceAccountChange.SQL2017.RS.Integration.Tests.ps1 index 4c2a85b76..9efd3aa83 100644 --- a/tests/Integration/Commands/Post.ServiceAccountChange.SQL2017.RS.Integration.Tests.ps1 +++ b/tests/Integration/Commands/Post.ServiceAccountChange.SQL2017.RS.Integration.Tests.ps1 @@ -106,7 +106,7 @@ Describe 'Post.ServiceAccountChange.SQL2017.RS' -Tag @('Integration_SQL2017_RS') ErrorAction = 'Stop' } - Invoke-SqlDscQuery @invokeSqlDscQueryParameters + $null = Invoke-SqlDscQuery @invokeSqlDscQueryParameters } It 'Should restart the Reporting Services service after granting rights' { @@ -126,7 +126,7 @@ Describe 'Post.ServiceAccountChange.SQL2017.RS' -Tag @('Integration_SQL2017_RS') # Refresh configuration after removing encrypted information $script:configuration = Get-SqlDscRSConfiguration -InstanceName $script:instanceName -ErrorAction 'Stop' - $script:configuration | Set-SqlDscRSDatabaseConnection -ServerName $script:computerName -InstanceName 'RSDB' -DatabaseName 'ReportServer' -Force -ErrorAction 'Stop' + $script:configuration | Set-SqlDscRSDatabaseConnection -ServerName $script:computerName -InstanceName 'RSDB' -DatabaseName $script:databaseName -Force -ErrorAction 'Stop' } } @@ -144,7 +144,7 @@ Describe 'Post.ServiceAccountChange.SQL2017.RS' -Tag @('Integration_SQL2017_RS') # Refresh configuration $script:configuration = Get-SqlDscRSConfiguration -InstanceName $script:instanceName -ErrorAction 'Stop' - $script:configuration | Initialize-SqlDscRS -Force -ErrorAction 'Stop' + $null = $script:configuration | Initialize-SqlDscRS -Force -ErrorAction 'Stop' } It 'Should have an initialized instance after re-initialization' { diff --git a/tests/Integration/Commands/Post.ServiceAccountChange.SQL2019-2022.RS.Integration.Tests.ps1 b/tests/Integration/Commands/Post.ServiceAccountChange.SQL2019-2022.RS.Integration.Tests.ps1 index ca297688a..9827a9cf7 100644 --- a/tests/Integration/Commands/Post.ServiceAccountChange.SQL2019-2022.RS.Integration.Tests.ps1 +++ b/tests/Integration/Commands/Post.ServiceAccountChange.SQL2019-2022.RS.Integration.Tests.ps1 @@ -103,7 +103,7 @@ Describe 'Post.ServiceAccountChange.SQL2019-2022.RS' -Tag @('Integration_SQL2019 ErrorAction = 'Stop' } - Invoke-SqlDscQuery @invokeSqlDscQueryParameters + $null = Invoke-SqlDscQuery @invokeSqlDscQueryParameters } It 'Should restart the Reporting Services service after granting rights' { @@ -141,7 +141,7 @@ Describe 'Post.ServiceAccountChange.SQL2019-2022.RS' -Tag @('Integration_SQL2019 # Refresh configuration $script:configuration = Get-SqlDscRSConfiguration -InstanceName $script:instanceName -ErrorAction 'Stop' - $script:configuration | Initialize-SqlDscRS -Force -ErrorAction 'Stop' + $null = $script:configuration | Initialize-SqlDscRS -Force -ErrorAction 'Stop' } It 'Should have an initialized instance after re-initialization' { diff --git a/tests/Integration/Commands/Remove-SqlDscRSEncryptedInformation.Integration.Tests.ps1 b/tests/Integration/Commands/Remove-SqlDscRSEncryptedInformation.Integration.Tests.ps1 index 390771804..db42f8e80 100644 --- a/tests/Integration/Commands/Remove-SqlDscRSEncryptedInformation.Integration.Tests.ps1 +++ b/tests/Integration/Commands/Remove-SqlDscRSEncryptedInformation.Integration.Tests.ps1 @@ -39,10 +39,6 @@ BeforeAll { #> Describe 'Remove-SqlDscRSEncryptedInformation' { - BeforeAll { - Start-Sleep -Seconds 300 - } - Context 'When removing encrypted information for SQL Server 2017 Reporting Services' -Tag @('Integration_SQL2017_RS') -Skip:$true { BeforeAll { $script:configuration = Get-SqlDscRSConfiguration -InstanceName 'SSRS' -ErrorAction 'Stop' diff --git a/tests/Unit/Private/Invoke-RsCimMethod.Tests.ps1 b/tests/Unit/Private/Invoke-RsCimMethod.Tests.ps1 index 9554220c9..1827cc690 100644 --- a/tests/Unit/Private/Invoke-RsCimMethod.Tests.ps1 +++ b/tests/Unit/Private/Invoke-RsCimMethod.Tests.ps1 @@ -280,7 +280,7 @@ Describe 'Invoke-RsCimMethod' -Tag 'Private' { Should -Throw -ExpectedMessage '*TestMethod*HRESULT: 1*Extended error message*' } - # 1 initial + 3 retries = 4 attempts + # 1 initial + 2 retries = 3 attempts Should -Invoke -CommandName Invoke-CimMethod -Exactly -Times 3 Should -Invoke -CommandName Start-Sleep -Exactly -Times 2 } From 9b642b83b2a23229eedb7a3ae3ab8b82886844b1 Mon Sep 17 00:00:00 2001 From: Johan Ljunggren Date: Sun, 11 Jan 2026 12:39:15 +0100 Subject: [PATCH 10/12] Suppress output from Set-SqlDscRSDatabaseConnection to improve test clarity --- .../Post.ServiceAccountChange.SQL2017.RS.Integration.Tests.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Integration/Commands/Post.ServiceAccountChange.SQL2017.RS.Integration.Tests.ps1 b/tests/Integration/Commands/Post.ServiceAccountChange.SQL2017.RS.Integration.Tests.ps1 index 9efd3aa83..029799188 100644 --- a/tests/Integration/Commands/Post.ServiceAccountChange.SQL2017.RS.Integration.Tests.ps1 +++ b/tests/Integration/Commands/Post.ServiceAccountChange.SQL2017.RS.Integration.Tests.ps1 @@ -126,7 +126,7 @@ Describe 'Post.ServiceAccountChange.SQL2017.RS' -Tag @('Integration_SQL2017_RS') # Refresh configuration after removing encrypted information $script:configuration = Get-SqlDscRSConfiguration -InstanceName $script:instanceName -ErrorAction 'Stop' - $script:configuration | Set-SqlDscRSDatabaseConnection -ServerName $script:computerName -InstanceName 'RSDB' -DatabaseName $script:databaseName -Force -ErrorAction 'Stop' + $null = $script:configuration | Set-SqlDscRSDatabaseConnection -ServerName $script:computerName -InstanceName 'RSDB' -DatabaseName $script:databaseName -Force -ErrorAction 'Stop' } } From 2c86fc51eda5324b0940903b06e16778be77be40 Mon Sep 17 00:00:00 2001 From: Johan Ljunggren Date: Sun, 11 Jan 2026 13:15:12 +0100 Subject: [PATCH 11/12] Add wait time in BeforeAll to ensure SQL Server Reporting Services is fully started after removing encryption key --- ...ove-SqlDscRSEncryptedInformation.Integration.Tests.ps1 | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/Integration/Commands/Remove-SqlDscRSEncryptedInformation.Integration.Tests.ps1 b/tests/Integration/Commands/Remove-SqlDscRSEncryptedInformation.Integration.Tests.ps1 index db42f8e80..ffec1c4fd 100644 --- a/tests/Integration/Commands/Remove-SqlDscRSEncryptedInformation.Integration.Tests.ps1 +++ b/tests/Integration/Commands/Remove-SqlDscRSEncryptedInformation.Integration.Tests.ps1 @@ -39,6 +39,14 @@ BeforeAll { #> Describe 'Remove-SqlDscRSEncryptedInformation' { + BeforeAll { + <# + Wait for SQL Server Reporting Services to be fully started after we + remove the encryption key in prior integration tests. + #> + Start-Sleep -Seconds 300 + } + Context 'When removing encrypted information for SQL Server 2017 Reporting Services' -Tag @('Integration_SQL2017_RS') -Skip:$true { BeforeAll { $script:configuration = Get-SqlDscRSConfiguration -InstanceName 'SSRS' -ErrorAction 'Stop' From 1bb7b38b61575c2a489889ebdf425bb9b5852cc1 Mon Sep 17 00:00:00 2001 From: Johan Ljunggren Date: Sun, 11 Jan 2026 13:22:35 +0100 Subject: [PATCH 12/12] Improve wait logic in Remove-SqlDscRSEncryptedInformation tests to ensure SQL Server Reporting Services is fully operational after removing the encryption key --- ...qlDscRSEncryptedInformation.Integration.Tests.ps1 | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/Integration/Commands/Remove-SqlDscRSEncryptedInformation.Integration.Tests.ps1 b/tests/Integration/Commands/Remove-SqlDscRSEncryptedInformation.Integration.Tests.ps1 index ffec1c4fd..cd2f90340 100644 --- a/tests/Integration/Commands/Remove-SqlDscRSEncryptedInformation.Integration.Tests.ps1 +++ b/tests/Integration/Commands/Remove-SqlDscRSEncryptedInformation.Integration.Tests.ps1 @@ -41,8 +41,18 @@ BeforeAll { Describe 'Remove-SqlDscRSEncryptedInformation' { BeforeAll { <# - Wait for SQL Server Reporting Services to be fully started after we + Wait for SQL Server Reporting Services to be fully operational after we remove the encryption key in prior integration tests. + + This is needed because the service seems to take some time to become fully + operational again, because this integration test fails intermittently. + + There a no known wait mechanism available that we can use to detect when + the service is fully operational again, so we use a fixed wait time here. + + TODO: Maybe it is possible to poll the file logs or Application event + log for an event in the command New-SqlDscRSEncryptionKey to determine + when the service is fully operational again and not return until it is. #> Start-Sleep -Seconds 300 }