Files
PacketStation/scripts/tests/Request-VsqlServerCertificate.Tests.ps1
T

717 lines
41 KiB
PowerShell
Raw Normal View History

#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=<new thumbprint>* | instance MSSQLSERVER', 'set Certificate=<new thumbprint>* | 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'
}
}