diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a93c624c2..b63b84773d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added integration tests for `Test-SqlDscIsLogin` command to ensure it functions correctly in real environments [issue #2230](https://github.com/dsccommunity/SqlServerDsc/issues/2230). +- Added integration tests for `Test-SqlDscIsDatabasePrincipal` command to ensure it + functions correctly in real environments + [issue #2231](https://github.com/dsccommunity/SqlServerDsc/issues/2231). - Added integration tests for `Set-SqlDscAudit` command to ensure it functions correctly in real environments [issue #2236](https://github.com/dsccommunity/SqlServerDsc/issues/2236). diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 7a5b366192..1c032b87ca 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -317,6 +317,7 @@ stages: 'tests/Integration/Commands/New-SqlDscRole.Integration.Tests.ps1' 'tests/Integration/Commands/Get-SqlDscRole.Integration.Tests.ps1' 'tests/Integration/Commands/Test-SqlDscIsRole.Integration.Tests.ps1' + 'tests/Integration/Commands/Test-SqlDscIsDatabasePrincipal.Integration.Tests.ps1' 'tests/Integration/Commands/Grant-SqlDscServerPermission.Integration.Tests.ps1' 'tests/Integration/Commands/Get-SqlDscServerPermission.Integration.Tests.ps1' 'tests/Integration/Commands/Set-SqlDscServerPermission.Integration.Tests.ps1' diff --git a/source/Public/Remove-SqlDscDatabase.ps1 b/source/Public/Remove-SqlDscDatabase.ps1 index 40475227a7..8b21e95036 100644 --- a/source/Public/Remove-SqlDscDatabase.ps1 +++ b/source/Public/Remove-SqlDscDatabase.ps1 @@ -24,6 +24,13 @@ on instances with a large amount of databases it might be better to make sure the **ServerObject** is recent enough, or pass in **DatabaseObject**. + .PARAMETER DropConnections + Specifies that all active connections to the database should be dropped + before removing the database. This sets the database to single-user mode + with immediate rollback of active transactions, which forcibly disconnects + all users and allows the database to be removed even when there are + active connections. + .EXAMPLE $serverObject = Connect-SqlDscDatabaseEngine -InstanceName 'MyInstance' $databaseObject = $serverObject | Get-SqlDscDatabase -Name 'MyDatabase' @@ -43,6 +50,14 @@ Removes the database named **MyDatabase** without prompting for confirmation. + .EXAMPLE + $serverObject = Connect-SqlDscDatabaseEngine -InstanceName 'MyInstance' + $serverObject | Remove-SqlDscDatabase -Name 'MyDatabase' -DropConnections -Force + + Drops all active connections to the database named **MyDatabase** and then removes it + without prompting for confirmation. This is useful when the database has active + connections that prevent removal. + .OUTPUTS None. #> @@ -72,7 +87,11 @@ function Remove-SqlDscDatabase [Parameter(ParameterSetName = 'ServerObject')] [System.Management.Automation.SwitchParameter] - $Refresh + $Refresh, + + [Parameter()] + [System.Management.Automation.SwitchParameter] + $DropConnections ) process @@ -126,6 +145,23 @@ function Remove-SqlDscDatabase { try { + # Drop all active connections if requested + if ($DropConnections.IsPresent) + { + Write-Verbose -Message ($script:localizedData.Database_DroppingConnections -f $Name) + + try + { + $DatabaseObject.UserAccess = 'Single' + $DatabaseObject.Alter([Microsoft.SqlServer.Management.Smo.TerminationClause]::RollbackTransactionsImmediately) + } + catch + { + $errorMessage = $script:localizedData.Database_DropConnectionsFailed -f $Name + New-InvalidOperationException -Message $errorMessage -ErrorRecord $_ + } + } + Write-Verbose -Message ($script:localizedData.Database_Removing -f $Name) $DatabaseObject.Drop() diff --git a/source/en-US/SqlServerDsc.strings.psd1 b/source/en-US/SqlServerDsc.strings.psd1 index 7235f33528..c6ed3ecbf8 100644 --- a/source/en-US/SqlServerDsc.strings.psd1 +++ b/source/en-US/SqlServerDsc.strings.psd1 @@ -384,6 +384,8 @@ ConvertFrom-StringData @' Database_Removed = Database '{0}' was removed successfully. Database_RemoveFailed = Failed to remove database '{0}' from instance '{1}'. Database_CannotRemoveSystem = Cannot remove system database '{0}'. + Database_DroppingConnections = Dropping all active connections to database '{0}'. + Database_DropConnectionsFailed = Failed to drop active connections for database '{0}'. Database_Remove_ShouldProcessVerboseDescription = Removing the database '{0}' from the instance '{1}'. Database_Remove_ShouldProcessVerboseWarning = Are you sure you want to remove the database '{0}'? # This string shall not end with full stop (.) since it is used as a title of ShouldProcess messages. diff --git a/tests/Integration/Commands/Invoke-SqlDscQuery.Integration.Tests.ps1 b/tests/Integration/Commands/Invoke-SqlDscQuery.Integration.Tests.ps1 index 622769e1e9..a4f46ff582 100644 --- a/tests/Integration/Commands/Invoke-SqlDscQuery.Integration.Tests.ps1 +++ b/tests/Integration/Commands/Invoke-SqlDscQuery.Integration.Tests.ps1 @@ -65,7 +65,7 @@ INSERT INTO TestTable (Name, Value) VALUES ('Test1', 100), ('Test2', 200), ('Tes { try { - Remove-SqlDscDatabase -ServerObject $script:serverObject -Name $script:testDatabaseName -Force -ErrorAction 'Stop' + Remove-SqlDscDatabase -ServerObject $script:serverObject -Name $script:testDatabaseName -Force -DropConnections -ErrorAction 'Stop' } catch { diff --git a/tests/Integration/Commands/README.md b/tests/Integration/Commands/README.md index f4c936c349..14f3c07239 100644 --- a/tests/Integration/Commands/README.md +++ b/tests/Integration/Commands/README.md @@ -71,6 +71,7 @@ Test-SqlDscIsLoginEnabled | 2 | 1 (Install-SqlDscServer), 0 (Prerequisites) | DS New-SqlDscRole | 2 | 1 (Install-SqlDscServer), 0 (Prerequisites) | DSCSQLTEST | SqlDscIntegrationTestRole_Persistent role Get-SqlDscRole | 2 | 1 (Install-SqlDscServer), 0 (Prerequisites) | DSCSQLTEST | - Test-SqlDscIsRole | 2 | 1 (Install-SqlDscServer), 0 (Prerequisites) | DSCSQLTEST | - +Test-SqlDscIsDatabasePrincipal | 2 | 1 (Install-SqlDscServer), 0 (Prerequisites) | DSCSQLTEST | Test database and database principals Grant-SqlDscServerPermission | 2 | 1 (Install-SqlDscServer), 0 (Prerequisites) | DSCSQLTEST | Grants CreateEndpoint permission to role Get-SqlDscServerPermission | 2 | 1 (Install-SqlDscServer), 0 (Prerequisites) | DSCSQLTEST | - Set-SqlDscServerPermission | 2 | 1 (Install-SqlDscServer), 0 (Prerequisites) | DSCSQLTEST | - diff --git a/tests/Integration/Commands/Test-SqlDscIsDatabasePrincipal.Integration.Tests.ps1 b/tests/Integration/Commands/Test-SqlDscIsDatabasePrincipal.Integration.Tests.ps1 new file mode 100644 index 0000000000..4801332ed2 --- /dev/null +++ b/tests/Integration/Commands/Test-SqlDscIsDatabasePrincipal.Integration.Tests.ps1 @@ -0,0 +1,226 @@ +[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 $script:moduleName -Force -ErrorAction 'Stop' +} + +Describe 'Test-SqlDscIsDatabasePrincipal' -Tag @('Integration_SQL2017', 'Integration_SQL2019', 'Integration_SQL2022') { + BeforeAll { + $script:mockInstanceName = 'DSCSQLTEST' + + $mockSqlAdministratorUserName = 'SqlAdmin' # Using computer name as NetBIOS name throw exception. + $mockSqlAdministratorPassword = ConvertTo-SecureString -String 'P@ssw0rd1' -AsPlainText -Force + + $script:mockSqlAdminCredential = [System.Management.Automation.PSCredential]::new($mockSqlAdministratorUserName, $mockSqlAdministratorPassword) + + $script:serverObject = Connect-SqlDscDatabaseEngine -InstanceName $script:mockInstanceName -Credential $script:mockSqlAdminCredential + + # Use a test database that should exist - we'll create it if it doesn't exist + $script:testDatabaseName = 'IntegrationTestDatabase' + + # Create test database if it doesn't exist + if (-not $script:serverObject.Databases[$script:testDatabaseName]) + { + $null = New-SqlDscDatabase -ServerObject $script:serverObject -Name $script:testDatabaseName -Force -ErrorAction 'Stop' + } + + # Test principals that should exist in the database + $script:testUserName = 'IntegrationTestUser' + $script:testRoleName = 'IntegrationTestRole' + $script:testAppRoleName = 'IntegrationTestAppRole' + + # Create test database user if it doesn't exist + $testDatabase = $script:serverObject.Databases[$script:testDatabaseName] + if (-not $testDatabase.Users[$script:testUserName]) + { + $sqlCommand = "USE [$script:testDatabaseName]; CREATE USER [$script:testUserName] WITHOUT LOGIN;" + $null = $script:serverObject.ConnectionContext.ExecuteNonQuery($sqlCommand) + } + + # Create test database role if it doesn't exist + if (-not $testDatabase.Roles[$script:testRoleName]) + { + $sqlCommand = "USE [$script:testDatabaseName]; CREATE ROLE [$script:testRoleName];" + $null = $script:serverObject.ConnectionContext.ExecuteNonQuery($sqlCommand) + } + + # Create test application role if it doesn't exist + if (-not $testDatabase.ApplicationRoles[$script:testAppRoleName]) + { + $sqlCommand = "USE [$script:testDatabaseName]; CREATE APPLICATION ROLE [$script:testAppRoleName] WITH PASSWORD = 'TestPassword123';" + $null = $script:serverObject.ConnectionContext.ExecuteNonQuery($sqlCommand) + } + + # Refresh database objects to ensure they are loaded + $null = $testDatabase.Users.Refresh() + $null = $testDatabase.Roles.Refresh() + $null = $testDatabase.ApplicationRoles.Refresh() + } + + AfterAll { + Disconnect-SqlDscDatabaseEngine -ServerObject $script:serverObject + } + + Context 'When testing database user existence' { + It 'Should return True when database user exists' { + $result = Test-SqlDscIsDatabasePrincipal -ServerObject $script:serverObject -DatabaseName $script:testDatabaseName -Name $script:testUserName + + $result | Should -BeOfType [System.Boolean] + $result | Should -BeTrue + } + + It 'Should return False when database user does not exist' { + $result = Test-SqlDscIsDatabasePrincipal -ServerObject $script:serverObject -DatabaseName $script:testDatabaseName -Name 'NonExistentUser' + + $result | Should -BeOfType [System.Boolean] + $result | Should -BeFalse + } + + It 'Should return False when database user exists but ExcludeUsers is specified' { + $result = Test-SqlDscIsDatabasePrincipal -ServerObject $script:serverObject -DatabaseName $script:testDatabaseName -Name $script:testUserName -ExcludeUsers + + $result | Should -BeOfType [System.Boolean] + $result | Should -BeFalse + } + + It 'Should return True for dbo user' { + $result = Test-SqlDscIsDatabasePrincipal -ServerObject $script:serverObject -DatabaseName $script:testDatabaseName -Name 'dbo' + + $result | Should -BeOfType [System.Boolean] + $result | Should -BeTrue + } + } + + Context 'When testing database role existence' { + It 'Should return True when database role exists' { + $result = Test-SqlDscIsDatabasePrincipal -ServerObject $script:serverObject -DatabaseName $script:testDatabaseName -Name $script:testRoleName + + $result | Should -BeOfType [System.Boolean] + $result | Should -BeTrue + } + + It 'Should return True when fixed role exists' { + # Test with built-in db_datareader role + $result = Test-SqlDscIsDatabasePrincipal -ServerObject $script:serverObject -DatabaseName $script:testDatabaseName -Name 'db_datareader' + + $result | Should -BeOfType [System.Boolean] + $result | Should -BeTrue + } + + It 'Should return False when fixed role exists but ExcludeFixedRoles is specified' { + $result = Test-SqlDscIsDatabasePrincipal -ServerObject $script:serverObject -DatabaseName $script:testDatabaseName -Name 'db_datareader' -ExcludeFixedRoles + + $result | Should -BeOfType [System.Boolean] + $result | Should -BeFalse + } + + It 'Should return False when database role exists but ExcludeRoles is specified' { + $result = Test-SqlDscIsDatabasePrincipal -ServerObject $script:serverObject -DatabaseName $script:testDatabaseName -Name $script:testRoleName -ExcludeRoles + + $result | Should -BeOfType [System.Boolean] + $result | Should -BeFalse + } + + It 'Should return True for user-defined role when ExcludeFixedRoles is specified' { + $result = Test-SqlDscIsDatabasePrincipal -ServerObject $script:serverObject -DatabaseName $script:testDatabaseName -Name $script:testRoleName -ExcludeFixedRoles + + $result | Should -BeOfType [System.Boolean] + $result | Should -BeTrue + } + } + + Context 'When testing application role existence' { + It 'Should return True when application role exists' { + $result = Test-SqlDscIsDatabasePrincipal -ServerObject $script:serverObject -DatabaseName $script:testDatabaseName -Name $script:testAppRoleName + + $result | Should -BeOfType [System.Boolean] + $result | Should -BeTrue + } + + It 'Should return False when application role exists but ExcludeApplicationRoles is specified' { + $result = Test-SqlDscIsDatabasePrincipal -ServerObject $script:serverObject -DatabaseName $script:testDatabaseName -Name $script:testAppRoleName -ExcludeApplicationRoles + + $result | Should -BeOfType [System.Boolean] + $result | Should -BeFalse + } + } + + Context 'When testing multiple exclusion parameters' { + It 'Should return False when principal exists but all types are excluded' { + $result = Test-SqlDscIsDatabasePrincipal -ServerObject $script:serverObject -DatabaseName $script:testDatabaseName -Name $script:testUserName -ExcludeUsers -ExcludeRoles -ExcludeApplicationRoles + + $result | Should -BeOfType [System.Boolean] + $result | Should -BeFalse + } + + It 'Should work with combination of exclusion parameters' { + # Test role when users and app roles are excluded + $result = Test-SqlDscIsDatabasePrincipal -ServerObject $script:serverObject -DatabaseName $script:testDatabaseName -Name $script:testRoleName -ExcludeUsers -ExcludeApplicationRoles + + $result | Should -BeOfType [System.Boolean] + $result | Should -BeTrue + } + } + + Context 'When testing pipeline parameter support' { + It 'Should accept ServerObject from pipeline' { + $result = $script:serverObject | Test-SqlDscIsDatabasePrincipal -DatabaseName $script:testDatabaseName -Name $script:testUserName + + $result | Should -BeOfType [System.Boolean] + $result | Should -BeTrue + } + } + + Context 'When testing error conditions' { + It 'Should throw when database does not exist' { + { Test-SqlDscIsDatabasePrincipal -ServerObject $script:serverObject -DatabaseName 'NonExistentDatabase' -Name 'SomePrincipal' -ErrorAction 'Stop' } | Should -Throw + } + } + + Context 'When testing case sensitivity' { + It 'Should handle case differences correctly for database names' { + # Database names are case-insensitive in SQL Server + $result1 = Test-SqlDscIsDatabasePrincipal -ServerObject $script:serverObject -DatabaseName $script:testDatabaseName.ToUpper() -Name $script:testUserName + $result2 = Test-SqlDscIsDatabasePrincipal -ServerObject $script:serverObject -DatabaseName $script:testDatabaseName.ToLower() -Name $script:testUserName + + $result1 | Should -BeOfType [System.Boolean] + $result2 | Should -BeOfType [System.Boolean] + $result1 | Should -Be $result2 + } + + It 'Should handle case differences correctly for principal names' { + # Principal names are case-insensitive in SQL Server + $result1 = Test-SqlDscIsDatabasePrincipal -ServerObject $script:serverObject -DatabaseName $script:testDatabaseName -Name $script:testUserName.ToUpper() + $result2 = Test-SqlDscIsDatabasePrincipal -ServerObject $script:serverObject -DatabaseName $script:testDatabaseName -Name $script:testUserName.ToLower() + + $result1 | Should -BeOfType [System.Boolean] + $result2 | Should -BeOfType [System.Boolean] + $result1 | Should -Be $result2 + } + } +} diff --git a/tests/Unit/Public/Remove-SqlDscDatabase.Tests.ps1 b/tests/Unit/Public/Remove-SqlDscDatabase.Tests.ps1 index a30b5c1f77..33e21d7a05 100644 --- a/tests/Unit/Public/Remove-SqlDscDatabase.Tests.ps1 +++ b/tests/Unit/Public/Remove-SqlDscDatabase.Tests.ps1 @@ -102,6 +102,10 @@ Describe 'Remove-SqlDscDatabase' -Tag 'Public' { $mockDatabaseObject | Add-Member -MemberType 'ScriptMethod' -Name 'Drop' -Value { # Mock implementation } -Force + $mockDatabaseObject | Add-Member -MemberType 'NoteProperty' -Name 'UserAccess' -Value 'Multiple' -Force + $mockDatabaseObject | Add-Member -MemberType 'ScriptMethod' -Name 'Alter' -Value { + # Mock implementation + } -Force } It 'Should remove database successfully using database object' { @@ -122,11 +126,68 @@ Describe 'Remove-SqlDscDatabase' -Tag 'Public' { } } + Context 'When using DropConnections parameter' { + BeforeAll { + $mockDatabaseObject = New-Object -TypeName 'Microsoft.SqlServer.Management.Smo.Database' + $mockDatabaseObject | Add-Member -MemberType 'NoteProperty' -Name 'Name' -Value 'TestDatabase' -Force + $mockDatabaseObject | Add-Member -MemberType 'NoteProperty' -Name 'UserAccess' -Value 'Multiple' -Force + $mockDatabaseObject | Add-Member -MemberType 'ScriptProperty' -Name 'Parent' -Value { + $mockParent = New-Object -TypeName 'Microsoft.SqlServer.Management.Smo.Server' + $mockParent | Add-Member -MemberType 'NoteProperty' -Name 'InstanceName' -Value 'TestInstance' -Force + return $mockParent + } -Force + + $script:alterCalled = $false + $mockDatabaseObject | Add-Member -MemberType 'ScriptMethod' -Name 'Alter' -Value { + $script:alterCalled = $true + if ($this.UserAccess -ne 'Single') { + throw 'UserAccess should be set to Single before calling Alter' + } + } -Force + + $mockDatabaseObject | Add-Member -MemberType 'ScriptMethod' -Name 'Drop' -Value { + if (-not $script:alterCalled) { + throw 'Alter should be called before Drop when DropConnections is specified' + } + } -Force + + $mockServerObject = New-Object -TypeName 'Microsoft.SqlServer.Management.Smo.Server' + $mockServerObject | Add-Member -MemberType 'NoteProperty' -Name 'InstanceName' -Value 'TestInstance' -Force + $mockServerObject | Add-Member -MemberType 'ScriptProperty' -Name 'Databases' -Value { + return @{ + 'TestDatabase' = $mockDatabaseObject + } | Add-Member -MemberType 'ScriptMethod' -Name 'Refresh' -Value { + # Mock implementation + } -PassThru -Force + } -Force + } + + It 'Should drop all active connections before removing database with ServerObject' { + $script:alterCalled = $false + $mockDatabaseObject.UserAccess = 'Multiple' + + $null = Remove-SqlDscDatabase -ServerObject $mockServerObject -Name 'TestDatabase' -DropConnections -Force + + $script:alterCalled | Should -BeTrue + $mockDatabaseObject.UserAccess | Should -Be 'Single' + } + + It 'Should drop all active connections before removing database with DatabaseObject' { + $script:alterCalled = $false + $mockDatabaseObject.UserAccess = 'Multiple' + + $null = Remove-SqlDscDatabase -DatabaseObject $mockDatabaseObject -DropConnections -Force + + $script:alterCalled | Should -BeTrue + $mockDatabaseObject.UserAccess | Should -Be 'Single' + } + } + Context 'Parameter validation' { It 'Should have the correct parameters in parameter set ServerObject' -ForEach @( @{ ExpectedParameterSetName = 'ServerObject' - ExpectedParameters = '-ServerObject -Name [-Force] [-Refresh] [-WhatIf] [-Confirm] []' + ExpectedParameters = '-ServerObject -Name [-Force] [-Refresh] [-DropConnections] [-WhatIf] [-Confirm] []' } ) { $result = (Get-Command -Name 'Remove-SqlDscDatabase').ParameterSets | @@ -143,7 +204,7 @@ Describe 'Remove-SqlDscDatabase' -Tag 'Public' { It 'Should have the correct parameters in parameter set DatabaseObject' -ForEach @( @{ ExpectedParameterSetName = 'DatabaseObject' - ExpectedParameters = '-DatabaseObject [-Force] [-WhatIf] [-Confirm] []' + ExpectedParameters = '-DatabaseObject [-Force] [-DropConnections] [-WhatIf] [-Confirm] []' } ) { $result = (Get-Command -Name 'Remove-SqlDscDatabase').ParameterSets | diff --git a/tests/Unit/Stubs/SMO.cs b/tests/Unit/Stubs/SMO.cs index bcfc5c12bd..828252f523 100644 --- a/tests/Unit/Stubs/SMO.cs +++ b/tests/Unit/Stubs/SMO.cs @@ -156,6 +156,13 @@ public enum SqlSmoState : int Dropped = 4, } + public enum TerminationClause : int + { + FailOnOpenTransactions = 0, + RollbackTransactionsImmediately = 1, + CloseAllConnectionsImmediately = 2 + } + #endregion Public Enums #region Public Classes @@ -592,15 +599,18 @@ public class Database public Database( Server server, string name ) { this.Name = name; + this.Parent = server; } public Database( Object server, string name ) { this.Name = name; + this.Parent = (Server)server; } public Database() {} public string Name; + public Server Parent; public void Create() { @@ -610,6 +620,14 @@ public void Drop() { } + public void Alter() + { + } + + public void Alter(TerminationClause terminationClause) + { + } + public Microsoft.SqlServer.Management.Smo.DatabasePermissionInfo[] EnumDatabasePermissions( string granteeName ) { List listOfDatabasePermissionInfo = new List();