2026-10-18 21:41:03 -06:00
# 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
2026-10-18 21:50:34 -06:00
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
2026-10-18 21:41:03 -06:00
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 ()
2026-10-18 21:50:34 -06:00
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 ()
2026-10-18 21:41:03 -06:00
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 }
2026-10-18 21:50:34 -06:00
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 ()
2026-10-18 21:41:03 -06:00
Now = { Get-Date }
Log = { param ( $m ) [ void ] $fake . Log . Add ( " $m " ) }. GetNewClosure ()
ShouldProcess = $null
}
2026-10-18 21:50:34 -06:00
foreach ( $svc in 'MSSQLSERVER' , 'MSSQL$DEV' , 'SQLSERVERAGENT' , 'SQLAgent$DEV' ) { [ void ] $fake . Running . Add ( $svc ) }
2026-10-18 21:41:03 -06:00
$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 ()
}
}
2026-10-18 21:50:34 -06:00
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
}
}
2026-10-18 21:41:03 -06:00
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'
}
}