Tests/Public/Sync-PWSHCertutilCASchema.Tests.ps1
|
BeforeAll { Import-Module (Resolve-Path "$PSScriptRoot\..\..\Posh-Certutil.psd1") -Force $script:TestJson = @' { "version": "1.0", "profiles": { "test-profile": { "description": "Test profile", "remoting": { "useTls": true, "port": 5986, "maxSessionsPerCA": 2 }, "cas": [ { "fqdn": "ca01.test.local", "displayName": "CA 01" }, { "fqdn": "ca02.test.local", "displayName": "CA 02" } ], "certutilView": { "restrict": { "issuedCerts": "Disposition=20", "revokedCerts": "Disposition=21", "expiringCerts": "Disposition=20,NotAfter>={EXPIRE_DATE}", "search": "{DYNAMIC}" }, "out": { "issuedCerts": ["RequestID","CommonName","NotBefore","NotAfter","SerialNumber","BogusField"], "revokedCerts": ["RequestID","CommonName","RevokedReason","RevokedEffectiveWhen"], "expiringCerts": ["RequestID","CommonName","NotAfter","SerialNumber"], "search": ["RequestID","CommonName","Disposition"] } } } } } '@ $script:TestConfigPath = [IO.Path]::GetTempFileName() Set-Content -Path $script:TestConfigPath -Value $script:TestJson -Encoding UTF8 InModuleScope Posh-Certutil -Parameters @{ ConfigPath = $script:TestConfigPath } { param($ConfigPath) $script:ConfigPath = $ConfigPath } $script:FakeSession = New-MockObject -Type System.Management.Automation.Runspaces.PSSession $script:MockSchemaFields = @( 'RequestID', 'RequesterName', 'CommonName', 'NotBefore', 'NotAfter', 'SerialNumber', 'CertificateTemplate', 'Disposition', 'RevokedReason', 'RevokedEffectiveWhen' ) } AfterAll { Remove-Item -Path $script:TestConfigPath -ErrorAction SilentlyContinue Remove-Module Posh-Certutil -ErrorAction SilentlyContinue } Describe 'Sync-PWSHCertutilCASchema' -Tag Unit { BeforeEach { # Restore config to known state before each test Set-Content -Path $script:TestConfigPath -Value $script:TestJson -Encoding UTF8 InModuleScope Posh-Certutil -Parameters @{ ConfigPath = $script:TestConfigPath } { param($ConfigPath) $script:ConfigPath = $ConfigPath } Mock -ModuleName Posh-Certutil Get-CASession { $script:FakeSession } Mock -ModuleName Posh-Certutil Invoke-CertutilSchema { @('stub') } Mock -ModuleName Posh-Certutil ConvertFrom-CertutilSchema { $script:MockSchemaFields } Mock -ModuleName Posh-Certutil Get-CertutilFieldNameMap { @{} } } Context 'Schema discovery (read-only)' { It 'Returns one result object per CA in the profile' { $results = Sync-PWSHCertutilCASchema -Profile 'test-profile' $results | Should -HaveCount 2 $results[0].CAServer | Should -Be 'ca01.test.local' $results[1].CAServer | Should -Be 'ca02.test.local' } It 'Sets Profile on every returned object' { $results = Sync-PWSHCertutilCASchema -Profile 'test-profile' $results | ForEach-Object { $_.Profile | Should -Be 'test-profile' } } It 'Populates AvailableFields and FieldCount from ConvertFrom-CertutilSchema output' { $result = Sync-PWSHCertutilCASchema -Profile 'test-profile' -CAFqdn 'ca01.test.local' $result.AvailableFields | Should -Contain 'RequestID' $result.AvailableFields | Should -Contain 'CommonName' $result.FieldCount | Should -Be $script:MockSchemaFields.Count } It 'Queries only the specified CA when -CAFqdn is provided' { Sync-PWSHCertutilCASchema -Profile 'test-profile' -CAFqdn 'ca01.test.local' | Out-Null Should -Invoke -ModuleName Posh-Certutil Get-CASession -Times 1 } It 'Throws when the specified -CAFqdn is not in the profile' { { Sync-PWSHCertutilCASchema -Profile 'test-profile' -CAFqdn 'ca99.test.local' } | Should -Throw } It 'Writes an error but does not throw when one CA fails' { Mock -ModuleName Posh-Certutil Get-CASession { if ($CAFqdn -eq 'ca01.test.local') { throw 'WinRM refused' } $script:FakeSession } { Sync-PWSHCertutilCASchema -Profile 'test-profile' -ErrorAction SilentlyContinue } | Should -Not -Throw } } Context 'Field validation' { It 'ValidatedOut excludes fields absent from the schema' { $result = Sync-PWSHCertutilCASchema -Profile 'test-profile' -CAFqdn 'ca01.test.local' $result.ValidatedOut.issuedCerts | Should -Not -Contain 'BogusField' $result.ValidatedOut.issuedCerts | Should -Contain 'RequestID' } It 'RemovedFields lists fields that are in config but missing from schema' { $result = Sync-PWSHCertutilCASchema -Profile 'test-profile' -CAFqdn 'ca01.test.local' $result.RemovedFields.issuedCerts | Should -Contain 'BogusField' } It 'Uses field intersection when multiple CAs are queried' { $script:SchemaCallCount = 0 Mock -ModuleName Posh-Certutil ConvertFrom-CertutilSchema { $script:SchemaCallCount++ if ($script:SchemaCallCount -eq 1) { @('RequestID', 'CommonName', 'SerialNumber', 'Disposition', 'RevokedReason', 'RevokedEffectiveWhen', 'NotBefore', 'NotAfter', 'CertificateTemplate') } else { # CA02 is missing CertificateTemplate — should be dropped from intersection @('RequestID', 'CommonName', 'SerialNumber', 'Disposition', 'RevokedReason', 'RevokedEffectiveWhen', 'NotBefore', 'NotAfter') } } $results = Sync-PWSHCertutilCASchema -Profile 'test-profile' $results[0].ValidatedOut.issuedCerts | Should -Not -Contain 'CertificateTemplate' $results[1].ValidatedOut.issuedCerts | Should -Not -Contain 'CertificateTemplate' } } Context 'Schema mismatch detection' { It 'SchemaConflicts is empty when all CAs return identical field sets' { # Default mock returns the same $script:MockSchemaFields for every call $results = Sync-PWSHCertutilCASchema -Profile 'test-profile' $conflicts = $results[0].SchemaConflicts | Get-Member -MemberType NoteProperty $conflicts | Should -BeNullOrEmpty } It 'SchemaConflicts is empty when only one CA is queried' { $result = Sync-PWSHCertutilCASchema -Profile 'test-profile' -CAFqdn 'ca01.test.local' $conflicts = $result.SchemaConflicts | Get-Member -MemberType NoteProperty $conflicts | Should -BeNullOrEmpty } It 'SchemaConflicts maps each divergent field to the CAs that have it' { $script:SchemaCallCount = 0 Mock -ModuleName Posh-Certutil ConvertFrom-CertutilSchema { $script:SchemaCallCount++ if ($script:SchemaCallCount -eq 1) { @('RequestID', 'CommonName', 'CertificateTemplate') } else { @('RequestID', 'CommonName') # CA02 lacks CertificateTemplate } } $results = Sync-PWSHCertutilCASchema -Profile 'test-profile' -WarningAction SilentlyContinue $results[0].SchemaConflicts.CertificateTemplate | Should -Contain 'ca01.test.local' $results[0].SchemaConflicts.CertificateTemplate | Should -Not -Contain 'ca02.test.local' } It 'Both result objects carry the same SchemaConflicts map' { $script:SchemaCallCount = 0 Mock -ModuleName Posh-Certutil ConvertFrom-CertutilSchema { $script:SchemaCallCount++ if ($script:SchemaCallCount -eq 1) { @('RequestID', 'CommonName', 'CertificateTemplate') } else { @('RequestID', 'CommonName') } } $results = Sync-PWSHCertutilCASchema -Profile 'test-profile' -WarningAction SilentlyContinue # The conflict map is profile-wide, not per-CA $results[0].SchemaConflicts.CertificateTemplate | Should -Be $results[1].SchemaConflicts.CertificateTemplate } It 'Emits a warning for each field that is not present on every CA' { $script:SchemaCallCount = 0 Mock -ModuleName Posh-Certutil ConvertFrom-CertutilSchema { $script:SchemaCallCount++ if ($script:SchemaCallCount -eq 1) { @('RequestID', 'CommonName', 'CertificateTemplate', 'RequesterName') } else { @('RequestID', 'CommonName') # missing CertificateTemplate + RequesterName } } $caught = $null Sync-PWSHCertutilCASchema -Profile 'test-profile' ` -WarningVariable caught -WarningAction SilentlyContinue | Out-Null # One warning per conflicting field $caught | Should -HaveCount 2 $caught | Where-Object { $_ -match 'CertificateTemplate' } | Should -Not -BeNullOrEmpty $caught | Where-Object { $_ -match 'RequesterName' } | Should -Not -BeNullOrEmpty } It 'Warning message identifies both the field and the CAs involved' { $script:SchemaCallCount = 0 Mock -ModuleName Posh-Certutil ConvertFrom-CertutilSchema { $script:SchemaCallCount++ if ($script:SchemaCallCount -eq 1) { @('RequestID', 'CommonName', 'CertificateTemplate') } else { @('RequestID', 'CommonName') } } $caught = $null Sync-PWSHCertutilCASchema -Profile 'test-profile' ` -WarningVariable caught -WarningAction SilentlyContinue | Out-Null $caught[0] | Should -Match 'Schema mismatch' $caught[0] | Should -Match 'CertificateTemplate' $caught[0] | Should -Match 'ca01.test.local' $caught[0] | Should -Match 'ca02.test.local' } } Context 'Config update (-UpdateConfig)' { It 'ConfigUpdated is $false without -UpdateConfig' { $result = Sync-PWSHCertutilCASchema -Profile 'test-profile' -CAFqdn 'ca01.test.local' $result.ConfigUpdated | Should -Be $false } It 'ConfigUpdated is $true when -UpdateConfig writes the file' { Mock -ModuleName Posh-Certutil Set-Content {} $result = Sync-PWSHCertutilCASchema -Profile 'test-profile' -CAFqdn 'ca01.test.local' -UpdateConfig $result.ConfigUpdated | Should -Be $true } It 'Does not write the file when -WhatIf is specified' { Mock -ModuleName Posh-Certutil Set-Content {} Sync-PWSHCertutilCASchema -Profile 'test-profile' -CAFqdn 'ca01.test.local' -UpdateConfig -WhatIf Should -Invoke -ModuleName Posh-Certutil Set-Content -Times 0 } It 'Writes JSON with invalid fields removed and valid fields preserved' { # Let Set-Content write to the real temp file; restore happens in BeforeEach Sync-PWSHCertutilCASchema -Profile 'test-profile' -CAFqdn 'ca01.test.local' -UpdateConfig $updated = Get-Content -Path $script:TestConfigPath -Raw | ConvertFrom-Json $out = $updated.profiles.'test-profile'.certutilView.out $out.issuedCerts | Should -Not -Contain 'BogusField' $out.issuedCerts | Should -Contain 'RequestID' $out.issuedCerts | Should -Contain 'CommonName' } It 'Writes syncState.lastSync and fieldNameMap when -UpdateConfig succeeds' { Mock -ModuleName Posh-Certutil Get-CertutilFieldNameMap { @{ 'Issued Request ID' = 'RequestID'; 'Issued Common Name' = 'CommonName' } } Sync-PWSHCertutilCASchema -Profile 'test-profile' -CAFqdn 'ca01.test.local' -UpdateConfig $updated = Get-Content -Path $script:TestConfigPath -Raw | ConvertFrom-Json $syncState = $updated.profiles.'test-profile'.syncState $syncState.lastSync | Should -Not -BeNullOrEmpty $syncState.fieldNameMap.'Issued Request ID' | Should -Be 'RequestID' $syncState.fieldNameMap.'Issued Common Name' | Should -Be 'CommonName' } } } |