#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0' } # Pester 5 tests for Request-VsqlServerCertificate.ps1. Run on Windows PowerShell 5.1 and PowerShell 7: # Invoke-Pester -Path scripts\tests -Output Detailed # The host (registry, services, certreq, icacls, machine store) is a fake. The certificates are # real, made with New-SelfSignedCertificate in Cert:\CurrentUser\My under the friendly name # below, and removed in AfterAll. Nothing touches the machine store, the registry or a CA. BeforeAll { . (Join-Path $PSScriptRoot '..\Request-VsqlServerCertificate.ps1') $script:TestFriendlyName = 'PS2-VSQL-CERT-TEST' $script:Root = 'HKLM:\SOFTWARE\Microsoft\Microsoft SQL Server' $script:KeyDefault = "$Root\MSSQL16.MSSQLSERVER\MSSQLServer\SuperSocketNetLib" $script:KeyDev = "$Root\MSSQL16.DEV\MSSQLServer\SuperSocketNetLib" $script:HostSans = 'DNS=vsql.pesco.com&DNS=vsql&DNS=sqlhost.pesco.com&DNS=SQLHOST' $script:Required = @('vsql.pesco.com', 'vsql', 'sqlhost.pesco.com', 'SQLHOST') Get-ChildItem Cert:\CurrentUser\My | Where-Object { $_.FriendlyName -eq $TestFriendlyName } | ForEach-Object { Remove-Item -LiteralPath "Cert:\CurrentUser\My\$($_.Thumbprint)" -DeleteKey } function New-TestCert { param( [string]$Sans = $HostSans, [string]$Provider = 'Microsoft RSA SChannel Cryptographic Provider', [string]$KeySpec = 'KeyExchange', [int]$KeyLength = 2048, [string]$Hash = 'SHA256', [string]$Eku = '1.3.6.1.5.5.7.3.1', [string[]]$KeyUsage = @('DigitalSignature', 'KeyEncipherment'), [datetime]$NotBefore = (Get-Date).AddDays(-1), [datetime]$NotAfter = (Get-Date).AddYears(1) ) $a = @{ Subject = 'CN=vsql.pesco.com' CertStoreLocation = 'Cert:\CurrentUser\My' FriendlyName = $TestFriendlyName Provider = $Provider KeyLength = $KeyLength HashAlgorithm = $Hash KeyUsage = $KeyUsage NotBefore = $NotBefore NotAfter = $NotAfter TextExtension = @("2.5.29.17={text}$Sans", "2.5.29.37={text}$Eku") } if ($KeySpec) { $a.KeySpec = $KeySpec } $c = New-SelfSignedCertificate @a Get-Item "Cert:\CurrentUser\My\$($c.Thumbprint)" } $script:Good = New-TestCert $script:Good2 = New-TestCert $script:Evil = New-TestCert -Sans 'DNS=vsql.pesco.com.evil.example&DNS=vsql.evil.example&DNS=sqlhost.pesco.com&DNS=SQLHOST' $script:Cased = New-TestCert -Sans 'DNS=VSQL.PESCO.COM&DNS=Vsql&DNS=SqlHost.Pesco.Com&DNS=sqlhost' $script:Ksp = New-TestCert -Provider 'Microsoft Software Key Storage Provider' -KeySpec '' # The SChannel CSP cannot hold a signature key, so these use the Enhanced RSA and AES CSP (type 24). $script:SigSpec = New-TestCert -Provider 'Microsoft Enhanced RSA and AES Cryptographic Provider' -KeySpec 'Signature' -KeyUsage 'DigitalSignature' $script:AesCsp = New-TestCert -Provider 'Microsoft Enhanced RSA and AES Cryptographic Provider' $script:Weak = New-TestCert -KeyLength 1024 $script:Sha1 = New-TestCert -Hash 'SHA1' $script:NoServerAuth = New-TestCert -Eku '1.3.6.1.5.5.7.3.2' $script:Expired = New-TestCert -NotBefore (Get-Date).AddDays(-60) -NotAfter (Get-Date).AddDays(-1) $script:Mutating = '^(certreq -new|certreq -submit|certreq -accept|icacls|SetReg|RemoveReg|Restart)' # A fake SQL host: default instance and DEV, service accounts, registry, machine store, # certreq and icacls. $Fake.Calls records every operation in order. function New-FakeHost { param([string]$Work) $fake = @{ Admin = $true Calls = New-Object System.Collections.ArrayList Log = New-Object System.Collections.ArrayList Instances = [ordered]@{ MSSQLSERVER = 'MSSQL16.MSSQLSERVER'; DEV = 'MSSQL16.DEV' } Services = @{ 'MSSQLSERVER' = [pscustomobject]@{ Name = 'MSSQLSERVER'; StartName = 'NT Service\MSSQLSERVER' } 'MSSQL$DEV' = [pscustomobject]@{ Name = 'MSSQL$DEV'; StartName = 'PESCO\svc-sqldev' } } Registry = @{ $KeyDefault = @{ Certificate = ''; ForceEncryption = '0' } $KeyDev = @{ Certificate = ''; ForceEncryption = '0' } } Store = @{} Submit = 'Issued' # Issued, Pending, Failure IssueCert = $Good IcaclsFail = $false SetFails = @{} # regKey -> $true: Set-ItemProperty throws SetIgnored = @{} # regKey -> $true: the write does not stick ReadFails = @{} # regKey -> $true: reading throws (access denied) Dependents = @{ 'MSSQLSERVER' = @('SQLSERVERAGENT'); 'MSSQL$DEV' = @('SQLAgent$DEV') } Running = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase) RestartFails = @{} # service -> $true: stops it and its dependents, then fails to start KeyFile = Join-Path $Work 'machinekey.bin' ShouldProcess = $null } New-Item -ItemType Directory -Force -Path $Work | Out-Null Set-Content -Path $fake.KeyFile -Value 'key' $realKeyInfo = ${function:Get-CertificateKeyInfo} $p = @{ WorkDir = $Work InstanceRoot = $Root IsAdmin = { [void]$fake.Calls.Add('IsAdmin'); $fake.Admin }.GetNewClosure() GetInstanceMap = { param($r) $fake.Instances }.GetNewClosure() GetService = { param($n) [void]$fake.Calls.Add("GetService $n"); $fake.Services[$n] }.GetNewClosure() TestRegKey = { param($k) $fake.Registry.ContainsKey($k) }.GetNewClosure() GetRegValue = { param($k, $n) if ($fake.ReadFails[$k]) { throw "Requested registry access is not allowed: $k" } $v = $fake.Registry[$k]; if ($v.ContainsKey($n)) { [string]$v[$n] } else { $null } }.GetNewClosure() SetRegValue = { param($k, $n, $v) [void]$fake.Calls.Add("SetReg $k $n=$v") if ($fake.SetFails[$k]) { throw "Access to $k is denied" } if (-not $fake.SetIgnored[$k]) { $fake.Registry[$k][$n] = $v } }.GetNewClosure() RemoveRegValue = { param($k, $n) [void]$fake.Calls.Add("RemoveReg $k $n"); if ($fake.SetFails[$k]) { throw "Access to $k is denied" }; $fake.Registry[$k].Remove($n) }.GetNewClosure() GetHostNames = { @('sqlhost.pesco.com', 'SQLHOST') } GetMachineCert = { param($t) $fake.Store[$t] }.GetNewClosure() Certreq = { param([string[]]$a) [void]$fake.Calls.Add("certreq $($a[0]) $($a[1..($a.Count - 1)] -join ' ')") switch ($a[0]) { '-new' { return [pscustomobject]@{ ExitCode = 0; Output = @('CertReq: Request Created') } } '-submit' { $cer = $a[-1] switch ($fake.Submit) { 'Issued' { [IO.File]::WriteAllBytes($cer, $fake.IssueCert.Export('Cert')); return [pscustomobject]@{ ExitCode = 0; Output = @('RequestId: 42', 'RequestId: "42"', 'Certificate retrieved(Issued) Issued') } } 'Pending' { return [pscustomobject]@{ ExitCode = 0; Output = @('RequestId: 43', 'RequestId: "43"', 'Certificate request is pending: Taken Under Submission (0)') } } default { return [pscustomobject]@{ ExitCode = -2146877436; Output = @('RequestId: 44', 'Certificate not issued (Denied) Denied by Policy Module') } } } } '-accept' { $c = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 (, [IO.File]::ReadAllBytes($a[-1])) $fake.Store[$c.Thumbprint] = Get-Item "Cert:\CurrentUser\My\$($c.Thumbprint)" return [pscustomobject]@{ ExitCode = 0; Output = @('Installed Certificate') } } } }.GetNewClosure() Icacls = { param([string[]]$a) [void]$fake.Calls.Add("icacls $($a -join ' ')") if ($fake.IcaclsFail) { return [pscustomobject]@{ ExitCode = 5; Output = @('Access is denied.') } } [pscustomobject]@{ ExitCode = 0; Output = @('processed file') } }.GetNewClosure() ResolveSid = { param($acct) @{ 'NT Service\MSSQLSERVER' = 'S-1-5-80-1'; 'PESCO\svc-sqldev' = 'S-1-5-21-9-1105' }[$acct] } # The real key reader on the real test certificate; only the store location is faked, # since the test keys live in the user store and vsql's in the machine store. GetKeyInfo = { param($c) $k = & $realKeyInfo $c; if ($k) { $k.Machine = $true }; $k }.GetNewClosure() KeyFilePath = { param($k) $fake.KeyFile }.GetNewClosure() TestFile = { param($f) Test-Path -LiteralPath $f -PathType Leaf } GetRunningDependents = { param($n) @($fake.Dependents[$n] | Where-Object { $fake.Running.Contains($_) }) }.GetNewClosure() RestartService = { param($n) [void]$fake.Calls.Add("Restart $n") foreach ($d in $fake.Dependents[$n]) { [void]$fake.Running.Remove($d) } [void]$fake.Running.Remove($n) if ($fake.RestartFails[$n]) { throw "Service '$n' cannot be started" } [void]$fake.Running.Add($n) }.GetNewClosure() StartService = { param($n) [void]$fake.Calls.Add("Start $n"); [void]$fake.Running.Add($n) }.GetNewClosure() Now = { Get-Date } Log = { param($m) [void]$fake.Log.Add("$m") }.GetNewClosure() ShouldProcess = $null } foreach ($svc in 'MSSQLSERVER', 'MSSQL$DEV', 'SQLSERVERAGENT', 'SQLAgent$DEV') { [void]$fake.Running.Add($svc) } $fake.Provider = $p return $fake } function Get-MutatingCalls($Fake) { @($Fake.Calls | Where-Object { $_ -match $Mutating }) } function Get-Snapshots($Fake) { @(Get-ChildItem -LiteralPath $Fake.Provider.WorkDir -Filter 'deploy-*.json' -File -ErrorAction SilentlyContinue | Sort-Object Name) } function Add-ToStore($Fake, $Cert) { $Fake.Store[$Cert.Thumbprint] = $Cert } } AfterAll { Get-ChildItem Cert:\CurrentUser\My | Where-Object { $_.FriendlyName -eq $TestFriendlyName } | ForEach-Object { Remove-Item -LiteralPath "Cert:\CurrentUser\My\$($_.Thumbprint)" -DeleteKey } } Describe 'Finding 1: administrator check' { BeforeEach { $f = New-FakeHost (Join-Path $TestDrive ([guid]::NewGuid())); Add-ToStore $f $Good } It 'refuses an unelevated session before any lookup or change' { $f.Admin = $false { Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good.Thumbprint } | Should -Throw '*elevated*' $f.Calls | Should -Be @('IsAdmin') } It 'refuses an unelevated rollback too' { $f.Admin = $false { Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback } | Should -Throw '*elevated*' } It 'lets an elevated session through' { $r = Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good.Thumbprint $r.Bound | Should -Be @('MSSQLSERVER', 'DEV') } It 'no longer calls Test-Admin with stray arguments' { $text = Get-Content (Join-Path $PSScriptRoot '..\Request-VsqlServerCertificate.ps1') -Raw $text | Should -Not -Match 'Test-Admin\s+-eq' $text | Should -Match 'if \(-not \(& \$P\.IsAdmin\)\)' } } Describe 'Finding 2: named instance service lookup' { It 'builds a WQL filter that keeps $ unescaped' { Get-ServiceFilter 'MSSQL$DEV' | Should -BeExactly "Name='MSSQL`$DEV'" Get-SqlServiceName 'DEV' | Should -BeExactly 'MSSQL$DEV' Get-SqlServiceName 'MSSQLSERVER' | Should -BeExactly 'MSSQLSERVER' } It 'runs that filter against real WMI without error (the old escaped form is an invalid query)' { { Get-CimInstance Win32_Service -Filter (Get-ServiceFilter 'MSSQL$DEV') -ErrorAction Stop } | Should -Not -Throw { Get-CimInstance Win32_Service -Filter "Name='MSSQL\`$DEV'" -ErrorAction Stop } | Should -Throw } It 'discovers the default and DEV instances with their accounts' { $f = New-FakeHost (Join-Path $TestDrive 'disc') $t = Get-SqlInstances $f.Provider $null $t.Name | Should -Be @('MSSQLSERVER', 'DEV') ($t | Where-Object Name -eq 'DEV').Service | Should -BeExactly 'MSSQL$DEV' ($t | Where-Object Name -eq 'DEV').Account | Should -Be 'PESCO\svc-sqldev' ($t | Where-Object Name -eq 'DEV').RegKey | Should -Be $KeyDev $f.Calls | Should -Contain 'GetService MSSQL$DEV' } It 'fails loudly when a service is missing' { $f = New-FakeHost (Join-Path $TestDrive 'nosvc') $f.Services.Remove('MSSQL$DEV') { Get-SqlInstances $f.Provider @('DEV') } | Should -Throw '*MSSQL$DEV*not found*' } } Describe 'Unknown instance names' { It 'rejects them before any change' { $f = New-FakeHost (Join-Path $TestDrive 'unknown') { Invoke-VsqlCertificateDeployment -Provider $f.Provider -Instances 'DEV', 'PROD' } | Should -Throw '*Unknown instance(s): PROD*' Get-MutatingCalls $f | Should -BeNullOrEmpty @($f.Calls | Where-Object { $_ -like 'certreq*' }) | Should -BeNullOrEmpty Test-Path (Join-Path $f.Provider.WorkDir '*.inf') | Should -BeFalse } } Describe 'Finding 3: SAN decoding and matching' { It 'decodes the DNS names from the raw extension' { $ext = $Good.Extensions | Where-Object { $_.Oid.Value -eq '2.5.29.17' } Get-SanDnsNames $ext.RawData | Should -Be @('vsql.pesco.com', 'vsql', 'sqlhost.pesco.com', 'SQLHOST') } It 'agrees with .NET where .NET can decode SANs (PowerShell 7)' { $ext = $Good.Extensions | Where-Object { $_.Oid.Value -eq '2.5.29.17' } if (-not ($ext.PSObject.Methods.Name -contains 'EnumerateDnsNames')) { Set-ItResult -Skipped -Because 'this .NET has no X509SubjectAlternativeNameExtension'; return } Get-SanDnsNames $ext.RawData | Should -Be @($ext.EnumerateDnsNames()) } It 'skips non-DNS entries and handles long-form lengths' { # SEQUENCE { [7] 4 bytes IP, [2] 130 bytes name } $name = 'a' * 126 + '.com' $dns = [byte[]](0x82, 0x81, 130) + [Text.Encoding]::ASCII.GetBytes($name) $ip = [byte[]](0x87, 4, 192, 168, 1, 1) $body = $ip + $dns $raw = [byte[]](0x30, 0x81, $body.Length) + $body Get-SanDnsNames $raw | Should -Be @($name) } It 'rejects malformed data' { { Get-SanDnsNames ([byte[]](0x30, 0x05, 0x82, 0x09, 0x61)) } | Should -Throw { Get-SanDnsNames ([byte[]](0x31, 0x00)) } | Should -Throw } It 'rejects vsql.pesco.com.evil.example for vsql.pesco.com and vsql' { $c = Test-ServerCertificate -Certificate $Evil -RequiredNames $Required $c.Problems | Should -Contain 'subject alternative name vsql.pesco.com missing' $c.Problems | Should -Contain 'subject alternative name vsql missing' @($c.Problems | Where-Object { $_ -like '*sqlhost*' -or $_ -like '*SQLHOST*' }) | Should -BeNullOrEmpty } It 'accepts case variants' { (Test-ServerCertificate -Certificate $Cased -RequiredNames $Required).Problems | Should -BeNullOrEmpty } } Describe 'Certificate validation on real keys' { It 'accepts a CSP exchange key, RSA 2048, SHA-256, Server Authentication, all SANs' { $c = Test-ServerCertificate -Certificate $Good -RequiredNames $Required $c.Problems | Should -BeNullOrEmpty $c.KeyInfo.ProviderType | Should -Be 12 $c.KeyInfo.KeySpec | Should -Be 1 $c.KeyInfo.UniqueName | Should -Not -BeNullOrEmpty } It 'rejects a KSP (CNG) key' { (Test-ServerCertificate -Certificate $Ksp -RequiredNames $Required).Problems -join " | " | Should -Match 'Key Storage Provider' } It 'rejects a CSP signature key' { $p = (Test-ServerCertificate -Certificate $SigSpec -RequiredNames $Required).Problems $p -join " | " | Should -Match 'AT_KEYEXCHANGE' $p | Should -Contain 'key usage lacks key encipherment' } It 'rejects a CSP other than the SChannel provider' { $p = (Test-ServerCertificate -Certificate $AesCsp -RequiredNames $Required).Problems @($p) | Should -HaveCount 1 $p -join " | " | Should -Match 'provider type 24' } It 'rejects a key under 2048 bits' { (Test-ServerCertificate -Certificate $Weak -RequiredNames $Required).Problems -join " | " | Should -Match 'key size 1024' } It 'rejects SHA-1' { (Test-ServerCertificate -Certificate $Sha1 -RequiredNames $Required).Problems -join " | " | Should -Match 'not SHA-256' } It 'rejects a certificate without Server Authentication' { (Test-ServerCertificate -Certificate $NoServerAuth -RequiredNames $Required).Problems | Should -Contain 'Server Authentication EKU missing' } It 'rejects an expired certificate' { (Test-ServerCertificate -Certificate $Expired -RequiredNames $Required).Problems -join " | " | Should -Match 'not valid now' } It 'rejects one that expires within 30 days' { (Test-ServerCertificate -Certificate $Good -RequiredNames $Required -Now $Good.NotAfter.AddDays(-10)).Problems -join " | " | Should -Match 'within 30 days' } It 'binds nothing when the issued certificate fails validation' { $f = New-FakeHost (Join-Path $TestDrive 'invalid') Add-ToStore $f $Ksp { Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Ksp.Thumbprint } | Should -Throw '*not usable*' Get-MutatingCalls $f | Should -BeNullOrEmpty Get-Snapshots $f | Should -BeNullOrEmpty } } Describe 'Certificate request INF' { It 'asks for exactly what SQL Server needs' { $inf = New-CertRequestInf 'vsql.pesco.com' $Required 'WebServer' '20261018-090000-000' $inf | Should -Match 'Subject = "CN=vsql.pesco.com"' $inf | Should -Match 'ProviderName = "Microsoft RSA SChannel Cryptographic Provider"' $inf | Should -Match 'ProviderType = 12' $inf | Should -Match 'KeySpec = 1' $inf | Should -Match 'KeyLength = 2048' $inf | Should -Match 'KeyAlgorithm = RSA' $inf | Should -Match 'HashAlgorithm = SHA256' $inf | Should -Match 'KeyUsage = 0xA0' $inf | Should -Match 'MachineKeySet = TRUE' $inf | Should -Match 'OID = 1\.3\.6\.1\.5\.5\.7\.3\.1' $inf | Should -Match 'CertificateTemplate = WebServer' $inf | Should -Match 'Signature = "\$Windows NT\$"' foreach ($n in $Required) { $inf | Should -Match ([regex]::Escape("_continue_ = `"dns=$n&`"")) } } } Describe 'A full request, issued' { It 'requests, accepts with -machine, validates, grants each account, snapshots and binds' { $f = New-FakeHost (Join-Path $TestDrive 'issued') $r = Invoke-VsqlCertificateDeployment -Provider $f.Provider $r.Thumbprint | Should -Be $Good.Thumbprint.ToLowerInvariant() $r.Bound | Should -Be @('MSSQLSERVER', 'DEV') $order = @($f.Calls | Where-Object { $_ -match $Mutating } | ForEach-Object { ($_ -split ' ')[0..1] -join ' ' }) $order[0..2] | Should -Be @('certreq -new', 'certreq -submit', 'certreq -accept') @($f.Calls | Where-Object { $_ -like 'certreq -accept*' })[0] | Should -Match '-machine -q' @($f.Calls | Where-Object { $_ -like 'icacls*' }) | Should -Be @("icacls $($f.KeyFile) /grant *S-1-5-80-1:(R)", "icacls $($f.KeyFile) /grant *S-1-5-21-9-1105:(R)") $f.Registry[$KeyDev].Certificate | Should -Be $r.Thumbprint $f.Registry[$KeyDev].ForceEncryption | Should -Be '0' @(Get-ChildItem $f.Provider.WorkDir -Filter 'request-*.json').Count | Should -Be 1 (Get-Content (Get-Snapshots $f)[0].FullName -Raw | ConvertFrom-Json).instances.previous | Should -Be @('', '') @($f.Calls | Where-Object { $_ -like 'Restart*' }) | Should -BeNullOrEmpty $f.Log | Should -Contain " Restart-Service -Name 'MSSQL`$DEV' -Force" } It 'restarts only with -RestartInstances' { $f = New-FakeHost (Join-Path $TestDrive 'restart') $r = Invoke-VsqlCertificateDeployment -Provider $f.Provider -Instances DEV -RestartInstances $r.Bound | Should -Be @('DEV') $f.Calls | Should -Contain 'Restart MSSQL$DEV' $f.Calls | Should -Not -Contain 'Restart MSSQLSERVER' $f.Registry[$KeyDefault].Certificate | Should -Be '' } It 'fails cleanly when the CA denies the request' { $f = New-FakeHost (Join-Path $TestDrive 'denied') $f.Submit = 'Failure' { Invoke-VsqlCertificateDeployment -Provider $f.Provider } | Should -Throw '*did not issue*request id 44*' @($f.Calls | Where-Object { $_ -match '^(icacls|SetReg|certreq -accept)' }) | Should -BeNullOrEmpty } } Describe 'Pending request' { It 'saves the request id, binds nothing, and resumes with -CerFile' { $f = New-FakeHost (Join-Path $TestDrive 'pending') $f.Submit = 'Pending' $err = $null try { Invoke-VsqlCertificateDeployment -Provider $f.Provider -Instances DEV } catch { $err = $_.Exception.Message } $err | Should -Match 'pending on the CA \(request id 43' $err | Should -Match 'certreq -retrieve -config "DC-02.PESCO.COM\\PESCO-PSYLOCKE-CA" 43' $err | Should -Match '-Instances DEV' @($f.Calls | Where-Object { $_ -match '^(icacls|SetReg|certreq -accept)' }) | Should -BeNullOrEmpty $req = Get-Content (Get-ChildItem $f.Provider.WorkDir -Filter 'request-*.json')[0].FullName -Raw | ConvertFrom-Json $req.id | Should -Be '43' $req.instances | Should -Be @('DEV') # The CA admin issues it; certreq -retrieve writes the .cer the record names. [IO.File]::WriteAllBytes($req.cer, $Good.Export('Cert')) $r = Invoke-VsqlCertificateDeployment -Provider $f.Provider -CerFile $req.cer -Instances DEV @($f.Calls | Where-Object { $_ -like 'certreq -accept*' })[0] | Should -Be "certreq -accept -machine -q $($req.cer)" $r.Bound | Should -Be @('DEV') $f.Registry[$KeyDev].Certificate | Should -Be $Good.Thumbprint.ToLowerInvariant() } } Describe 'Finding 5: declined or failed prerequisites' { BeforeEach { $f = New-FakeHost (Join-Path $TestDrive ([guid]::NewGuid())); Add-ToStore $f $Good } It 'a declined ACL grant cancels the run and binds nothing' { $f.Provider.ShouldProcess = { param($t, $a) $a -notlike 'grant read*' } $r = Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good.Thumbprint 3>$null $r.Cancelled | Should -BeTrue @($f.Calls | Where-Object { $_ -match '^(SetReg|RemoveReg|Restart|icacls)' }) | Should -BeNullOrEmpty Get-Snapshots $f | Should -BeNullOrEmpty } It 'a declined second grant cancels after the first, still binding nothing' { $count = @{ n = 0 } $f.Provider.ShouldProcess = { param($t, $a) if ($a -like 'grant read*') { $count.n++; return ($count.n -lt 2) }; $true }.GetNewClosure() $r = Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good.Thumbprint 3>$null $r.Cancelled | Should -BeTrue @($f.Calls | Where-Object { $_ -match '^(SetReg|RemoveReg|Restart)' }) | Should -BeNullOrEmpty } It 'a failed icacls stops the run and binds nothing' { $f.IcaclsFail = $true { Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good.Thumbprint } | Should -Throw '*Could not grant*Nothing was bound*' @($f.Calls | Where-Object { $_ -match '^(SetReg|RemoveReg|Restart)' }) | Should -BeNullOrEmpty Get-Snapshots $f | Should -BeNullOrEmpty } It 'declining certreq -new, -submit or -accept cancels before the next step' { foreach ($step in 'certreq -new', 'certreq -submit', 'certreq -accept') { $g = New-FakeHost (Join-Path $TestDrive ([guid]::NewGuid())) $g.Provider.ShouldProcess = { param($t, $a) $a -notlike "*$step*" }.GetNewClosure() $r = Invoke-VsqlCertificateDeployment -Provider $g.Provider 3>$null $r.Cancelled | Should -BeTrue @($g.Calls | Where-Object { $_ -like "$step*" }) | Should -BeNullOrEmpty @($g.Calls | Where-Object { $_ -match '^(icacls|SetReg)' }) | Should -BeNullOrEmpty } } It 'declining one instance binding leaves that instance out of the snapshot' { $f.Provider.ShouldProcess = { param($t, $a) $t -ne 'instance MSSQLSERVER' } $r = Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good.Thumbprint $r.Bound | Should -Be @('DEV') (Get-Content (Get-Snapshots $f)[0].FullName -Raw | ConvertFrom-Json).instances.name | Should -Be @('DEV') } } Describe 'Finding 4: snapshots per deployment' { It 'keeps the useful snapshot when the bound certificate is run again, and rolls back to it' { $f = New-FakeHost (Join-Path $TestDrive 'rerun') Add-ToStore $f $Good; Add-ToStore $f $Good2 $a = $Good.Thumbprint.ToLowerInvariant(); $b = $Good2.Thumbprint.ToLowerInvariant() $f.Registry[$KeyDefault].Certificate = $a; $f.Registry[$KeyDev].Certificate = $a $r1 = Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good2.Thumbprint $r1.Bound | Should -Be @('MSSQLSERVER', 'DEV') $snap = Get-Snapshots $f $snap.Count | Should -Be 1 $before = Get-FileHash $snap[0].FullName Start-Sleep -Milliseconds 20 $f.Calls.Clear() $r2 = Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good2.Thumbprint -RestartInstances $r2.Bound | Should -BeNullOrEmpty $r2.AlreadyBound | Should -Be @('MSSQLSERVER', 'DEV') $r2.Snapshot | Should -BeNullOrEmpty @($f.Calls | Where-Object { $_ -like 'SetReg*' }) | Should -BeNullOrEmpty $f.Calls | Should -Contain 'Restart MSSQL$DEV' (Get-Snapshots $f).Count | Should -Be 1 (Get-FileHash $snap[0].FullName).Hash | Should -Be $before.Hash $rb = Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback $rb.Restored | Should -Be @('MSSQLSERVER', 'DEV') $f.Registry[$KeyDefault].Certificate | Should -Be $a $f.Registry[$KeyDev].Certificate | Should -Be $a $b | Should -Not -Be $a } It 'never replaces an existing snapshot file' { $path = Join-Path $TestDrive 'immutable.json' Write-NewJsonFile $path @{ a = 1 } { Write-NewJsonFile $path @{ a = 2 } } | Should -Throw (Get-Content $path -Raw | ConvertFrom-Json).a | Should -Be 1 } It 'records an absent value as null and an empty value as empty' { $f = New-FakeHost (Join-Path $TestDrive 'nullempty') Add-ToStore $f $Good $f.Registry[$KeyDefault].Remove('Certificate') Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good.Thumbprint | Out-Null $s = Get-Content (Get-Snapshots $f)[0].FullName -Raw | ConvertFrom-Json $null -eq ($s.instances | Where-Object name -eq 'MSSQLSERVER').previous | Should -BeTrue ($s.instances | Where-Object name -eq 'DEV').previous | Should -BeExactly '' Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback | Out-Null $f.Registry[$KeyDefault].ContainsKey('Certificate') | Should -BeFalse $f.Registry[$KeyDev].Certificate | Should -BeExactly '' } } Describe 'Finding 6: rollback restores each instance on its own and verifies' { BeforeEach { $f = New-FakeHost (Join-Path $TestDrive ([guid]::NewGuid())) Add-ToStore $f $Good $f.Registry[$KeyDefault].Certificate = 'aaaa'; $f.Registry[$KeyDev].Certificate = 'bbbb' Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good.Thumbprint | Out-Null $f.Calls.Clear() } It 'restores and verifies every instance' { $r = Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback $r.Restored | Should -Be @('MSSQLSERVER', 'DEV') $f.Registry[$KeyDefault].Certificate | Should -Be 'aaaa' $f.Registry[$KeyDev].Certificate | Should -Be 'bbbb' $f.Log | Should -Contain 'Restored DEV to bbbb (verified)' } It 'keeps going past a failed instance and reports it' { $f.SetFails[$KeyDefault] = $true { Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback } | Should -Throw '*Rollback incomplete*MSSQLSERVER*denied*' $f.Registry[$KeyDev].Certificate | Should -Be 'bbbb' $f.Log | Should -Not -Contain 'Restored MSSQLSERVER to aaaa (verified)' } It 'reports a write that does not stick instead of claiming success' { $f.SetIgnored[$KeyDev] = $true { Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback } | Should -Throw "*DEV: reads*after restoring 'bbbb'*" $f.Registry[$KeyDefault].Certificate | Should -Be 'aaaa' } It 'restores only the named instances and rejects names not in the snapshot' { { Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback -Instances PROD } | Should -Throw '*not in any snapshot*' @($f.Calls | Where-Object { $_ -like 'SetReg*' }) | Should -BeNullOrEmpty $r = Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback -Instances DEV -RestartInstances $r.Restored | Should -Be @('DEV') $f.Calls | Should -Contain 'Restart MSSQL$DEV' $f.Registry[$KeyDefault].Certificate | Should -Be $Good.Thumbprint.ToLowerInvariant() # The next default rollback finishes the same snapshot with the instance left. $f.Calls.Clear() (Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback).Restored | Should -Be @('MSSQLSERVER') @($f.Calls | Where-Object { $_ -like "SetReg $KeyDev*" }) | Should -BeNullOrEmpty $f.Registry[$KeyDefault].Certificate | Should -Be 'aaaa' } It 'retries an instance whose restore failed' { $f.SetFails[$KeyDefault] = $true { Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback } | Should -Throw '*Rollback incomplete*' $f.SetFails.Clear() (Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback).Restored | Should -Be @('MSSQLSERVER') $f.Registry[$KeyDefault].Certificate | Should -Be 'aaaa' } It 'defaults to the newest snapshot not yet rolled back; -Snapshot picks one' { Add-ToStore $f $Good2 Start-Sleep -Milliseconds 20 Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good2.Thumbprint | Out-Null $snaps = Get-Snapshots $f $snaps.Count | Should -Be 2 (Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback).Snapshot | Should -Be $snaps[1].FullName $f.Registry[$KeyDev].Certificate | Should -Be $Good.Thumbprint.ToLowerInvariant() (Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback).Snapshot | Should -Be $snaps[0].FullName $f.Registry[$KeyDev].Certificate | Should -Be 'bbbb' # Both rolled back: a third run does nothing, it does not re-apply the newer snapshot. $f.Calls.Clear() (Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback).Snapshot | Should -BeNullOrEmpty @($f.Calls | Where-Object { $_ -like 'SetReg*' }) | Should -BeNullOrEmpty $f.Registry[$KeyDev].Certificate | Should -Be 'bbbb' (Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback -Snapshot $snaps[1].Name).Snapshot | Should -Be $snaps[1].FullName $f.Registry[$KeyDev].Certificate | Should -Be $Good.Thumbprint.ToLowerInvariant() } It 'automatic restore during binding restores each changed instance on its own' { Add-ToStore $f $Good2 $f.SetFails[$KeyDev] = $true $err = $null try { Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good2.Thumbprint } catch { $err = $_.Exception.Message } $err | Should -Match 'Binding failed' $err | Should -Match 'restore FAILED for:\s+- DEV' $f.Registry[$KeyDefault].Certificate | Should -Be $Good.Thumbprint.ToLowerInvariant() $f.Log | Should -Contain "Restored MSSQLSERVER to $($Good.Thumbprint.ToLowerInvariant()) (verified)" } It 'automatic restore after a write that does not stick reports success only when verified' { Add-ToStore $f $Good2 $f.SetIgnored[$KeyDev] = $true $err = $null try { Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good2.Thumbprint } catch { $err = $_.Exception.Message } $err | Should -Match 'DEV reads' $err | Should -Match 'Every instance changed in this run was restored and verified' $f.Registry[$KeyDefault].Certificate | Should -Be $Good.Thumbprint.ToLowerInvariant() } } Describe 'Second review: recovery paths' { BeforeEach { $f = New-FakeHost (Join-Path $TestDrive ([guid]::NewGuid())) Add-ToStore $f $Good $f.Registry[$KeyDefault].Certificate = 'aaaa'; $f.Registry[$KeyDev].Certificate = 'bbbb' Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good.Thumbprint | Out-Null $f.Calls.Clear() } It 'a rollback whose restart failed is retried, not skipped and not stepped past' { $f.RestartFails['MSSQL$DEV'] = $true { Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback -RestartInstances } | Should -Throw '*Rollback incomplete*MSSQL$DEV*' $f.Registry[$KeyDev].Certificate | Should -Be 'bbbb' $f.RestartFails.Clear() $f.Calls.Clear() $r = Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback -RestartInstances $r.Snapshot | Should -Not -BeNullOrEmpty $r.Restarted | Should -Be @('DEV') $f.Calls | Should -Contain 'Restart MSSQL$DEV' $f.Calls | Should -Not -Contain 'Restart MSSQLSERVER' $f.Registry[$KeyDev].Certificate | Should -Be 'bbbb' # Now complete: nothing left to do. (Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback -RestartInstances).Snapshot | Should -BeNullOrEmpty } It 'a declined restart under -Confirm also stays open' { $f.Provider.ShouldProcess = { param($t, $a) $a -notlike 'restart service*' -or $t -ne 'MSSQL$DEV' } Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback -RestartInstances | Out-Null $f.Provider.ShouldProcess = $null $r = Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback -RestartInstances $r.Restarted | Should -Be @('DEV') } It 'SQL Server Agent is started again after a failed restart is retried' { $f.RestartFails['MSSQL$DEV'] = $true { Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback -RestartInstances } | Should -Throw $f.Running.Contains('SQLAgent$DEV') | Should -BeFalse $depFile = Join-Path $f.Provider.WorkDir 'dependents-MSSQL_DEV.json' (Get-Content $depFile -Raw | ConvertFrom-Json).services | Should -Be @('SQLAgent$DEV') $f.RestartFails.Clear() Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback -RestartInstances | Out-Null $f.Calls | Should -Contain 'Start SQLAgent$DEV' $f.Running.Contains('SQLAgent$DEV') | Should -BeTrue Test-Path $depFile | Should -BeFalse } It 'a successful restart starts the agent that ran and leaves no dependents file' { Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good.Thumbprint -Instances MSSQLSERVER -RestartInstances | Out-Null $f.Calls | Should -Contain 'Start SQLSERVERAGENT' $f.Running.Contains('SQLSERVERAGENT') | Should -BeTrue @(Get-ChildItem $f.Provider.WorkDir -Filter 'dependents-*.json') | Should -BeNullOrEmpty } It 'a registry read failure stops a deployment before any snapshot or change' { Add-ToStore $f $Good2 $before = @(Get-Snapshots $f).Count $f.ReadFails[$KeyDev] = $true { Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good2.Thumbprint } | Should -Throw '*not allowed*' @($f.Calls | Where-Object { $_ -match '^(SetReg|RemoveReg)' }) | Should -BeNullOrEmpty @(Get-Snapshots $f).Count | Should -Be $before } It 'a registry read failure during rollback is a failure, never verified as absent' { $f.ReadFails[$KeyDev] = $true { Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback } | Should -Throw '*Rollback incomplete*DEV*not allowed*' $f.Log | Should -Not -Contain 'Restored DEV to bbbb (verified)' } } Describe 'Real registry operations (HKCU test key)' { BeforeAll { $script:TestRoot = "HKCU:\Software\PS2-VSQL-CERT-TEST-$([guid]::NewGuid())" New-Item -Path "$TestRoot\Instance Names\SQL" -Force | Out-Null New-ItemProperty -Path "$TestRoot\Instance Names\SQL" -Name 'PSREPORTING' -Value 'MSSQL16.PSREPORTING' | Out-Null New-ItemProperty -Path "$TestRoot\Instance Names\SQL" -Name 'DEV' -Value 'MSSQL16.DEV' | Out-Null New-Item -Path "$TestRoot\Empty" -Force | Out-Null New-ItemProperty -Path "$TestRoot\Empty" -Name 'Certificate' -Value '' | Out-Null New-Item -Path "$TestRoot\Absent" -Force | Out-Null $script:Real = Get-VsqlDefaultProvider } AfterAll { Remove-Item -LiteralPath $TestRoot -Recurse -Force } It 'lists every instance, including one whose name starts with PS' { $map = & $Real.GetInstanceMap $TestRoot @($map.Keys) | Should -Be @('PSREPORTING', 'DEV') $map['PSREPORTING'] | Should -Be 'MSSQL16.PSREPORTING' } It 'returns an empty map when no instance is registered' { (& $Real.GetInstanceMap "$TestRoot\Absent").Count | Should -Be 0 } It 'tells an empty value from an absent one' { & $Real.GetRegValue "$TestRoot\Empty" 'Certificate' | Should -BeExactly '' $null -eq (& $Real.GetRegValue "$TestRoot\Absent" 'Certificate') | Should -BeTrue } It 'throws, rather than reporting absent, when the key cannot be read' { { & $Real.GetRegValue "$TestRoot\Missing" 'Certificate' } | Should -Throw } It 'writes, reads back and removes through the real operations' { & $Real.SetRegValue "$TestRoot\Absent" 'Certificate' 'abcd' & $Real.GetRegValue "$TestRoot\Absent" 'Certificate' | Should -Be 'abcd' & $Real.RemoveRegValue "$TestRoot\Absent" 'Certificate' $null -eq (& $Real.GetRegValue "$TestRoot\Absent" 'Certificate') | Should -BeTrue } } Describe '-WhatIf' { It 'changes nothing on the real ShouldProcess path' { $f = New-FakeHost (Join-Path $TestDrive 'whatif-real') $r = Invoke-VsqlCertificateDeployment -Provider $f.Provider -RestartInstances -WhatIf 6>$null Get-MutatingCalls $f | Should -BeNullOrEmpty @(Get-ChildItem $f.Provider.WorkDir -File | Where-Object Name -ne 'machinekey.bin') | Should -BeNullOrEmpty $r.Bound | Should -BeNullOrEmpty } It 'previews every step of a request' { $f = New-FakeHost (Join-Path $TestDrive 'whatif-steps') $seen = New-Object System.Collections.ArrayList $f.Provider.ShouldProcess = { param($t, $a) [void]$seen.Add("$a | $t"); $false }.GetNewClosure() Invoke-VsqlCertificateDeployment -Provider $f.Provider -RestartInstances -WhatIf | Out-Null Get-MutatingCalls $f | Should -BeNullOrEmpty $expect = 'certreq -new', 'certreq -submit', 'certreq -accept', 'grant read to NT Service\MSSQLSERVER', 'grant read to PESCO\svc-sqldev', 'set Certificate=* | instance MSSQLSERVER', 'set Certificate=* | instance DEV', 'restart service* | MSSQLSERVER', 'restart service* | MSSQL$DEV' foreach ($e in $expect) { @($seen | Where-Object { $_ -like "*$e*" }).Count | Should -Be 1 -Because "the preview should include '$e'" } $f.Log -join "`n" | Should -Match 'ProviderType = 12' } It 'previews a rollback without changing it' { $f = New-FakeHost (Join-Path $TestDrive 'whatif-rollback') Add-ToStore $f $Good Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good.Thumbprint | Out-Null $f.Calls.Clear() Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback -RestartInstances -WhatIf 6>$null | Out-Null Get-MutatingCalls $f | Should -BeNullOrEmpty $f.Registry[$KeyDev].Certificate | Should -Be $Good.Thumbprint.ToLowerInvariant() } } Describe 'Account to SID' { It 'maps the built-in service accounts that icacls cannot name' { Resolve-AccountSid 'LocalSystem' | Should -Be 'S-1-5-18' Resolve-AccountSid 'NT AUTHORITY\NetworkService' | Should -Be 'S-1-5-20' Resolve-AccountSid 'NT AUTHORITY\LocalService' | Should -Be 'S-1-5-19' } It 'translates a real account' { Resolve-AccountSid "$env:USERDOMAIN\$env:USERNAME" | Should -Match '^S-1-5-21-' } } Describe 'Native commands' { It 'captures stderr and the exit code without throwing under Stop' { $ErrorActionPreference = 'Stop' $r = Invoke-NativeCommand 'cmd.exe' @('/c', 'echo out & echo err 1>&2 & exit 3') $r.ExitCode | Should -Be 3 $r.Output -join ' ' | Should -Match 'err' } }