- A rollback record now says which restarts succeeded. A snapshot stays open while an instance is unrestored or its restart failed (or was declined), so -Rollback -RestartInstances retries exactly those instead of reporting done or stepping back to an older deployment. - Running dependents (SQL Server Agent) are written to dependents-<service>.json before the stop and removed once they run again, so a failed restart does not leave Agent stopped for good. - GetRegValue returns $null only for a value confirmed absent; a missing key or read error throws, so it is never snapshotted or verified as absent. - Instances come from the value names of Instance Names\SQL; the 'PS*' filter dropped names such as PSREPORTING. Tests for each, including the real registry operations on an HKCU test key. Pester green on PowerShell 7.6 (63) and Windows PowerShell 5.1 (62, 1 skipped). Co-Authored-By: Claude Opus 5.5 <[email protected]>
717 lines
41 KiB
PowerShell
717 lines
41 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
|
|
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'
|
|
}
|
|
}
|