Files
PacketStation/scripts/tests/Request-VsqlServerCertificate.Tests.ps1
T
spencermandClaude Opus 5.5 90adcebc44
ci / frontend (push) Successful in 43s
ci / server (push) Successful in 11m3s
ci / image (push) Successful in 12s
ci / e2e (push) Failing after 4m2s
scripts: vsql certificate script fixes the six review findings, with a Pester suite on PowerShell 5.1 and 7
- Admin check uses -not (IsAdmin); the old call passed -eq $false as arguments.
- Named instances: WQL filter Name='MSSQL$DEV' unescaped, lookup errors surface.
- SANs decoded from the raw extension (DER walk) and matched as whole names.
- Immutable deploy-, request- and rollback- records; already-bound instances
  get no snapshot; -Rollback takes the newest snapshot not yet rolled back,
  -Snapshot picks one.
- certreq -new, -submit, -accept and the key grants go through ShouldProcess;
  a declined or failed prerequisite binds nothing.
- Rollback and automatic restore work per instance, read back, report failures.

Also found while testing: PowerShell 7 returns RSACng for a CSP key, so the
provider check now reads CERT_KEY_PROV_INFO; ForEach-Object member form returned
nothing under -WhatIf; stderr capture under Stop threw on 5.1; LocalSystem and
NetworkService accounts go to icacls as SIDs; SQL Agent is started again after
a restart; signature checked by OID.

DEPLOY.md gains the run procedure: preflight, DEV, docker-1 checks, production, rollback.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 21:41:03 -06:00

599 lines
34 KiB
PowerShell

#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
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) $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 }
RestartService = { param($n) [void]$fake.Calls.Add("Restart $n"); @() }.GetNewClosure()
Now = { Get-Date }
Log = { param($m) [void]$fake.Log.Add("$m") }.GetNewClosure()
ShouldProcess = $null
}
$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 '-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'
}
}