Skip to content

Six mocks in the Get-PSBuildCertificate tests never reach the module, and three tests fail on a machine that has a code-signing certificate #216

Description

@tablackburn

Six Mock Get-ChildItem statements in tests/Get-PSBuildCertificate.tests.ps1 are declared without -ModuleName 'PowerShellBuild', so they never reach the call inside the module. Three of the tests that rely on them pass only because the machine running them has no code-signing certificate installed.

What happens

Pester mocks are scoped to the session state they are declared in. A mock declared in a test file replaces the command for the test file; it does not replace the command for a function running inside an imported module. Get-PSBuildCertificate calls Get-ChildItem -Path $CertStoreLocation -CodeSigningCert from inside PowerShellBuild, so it gets the real cmdlet and the real certificate store.

The six unscoped mocks:

Line Test
24 Defaults to Auto mode when no CertificateSource is specified
42 Resolves to Store mode when SIGNCERTIFICATE environment variable is not set
61 Returns $null when no valid certificate is found
67 Filters out expired certificates
76 Filters out certificates without a private key
100 Returns $null when the specified thumbprint is not found

Measured

Adding Should -Invoke Get-ChildItem -Times 0 -Exactly to a copy of Returns $null when no valid certificate is found — an assertion that the mock was never called — passes. A module-scoped spy added alongside it records exactly one call, which is the one the function actually makes:

[+] A: the unscoped mock is never invoked
[+] B: the real Get-ChildItem inside the module is what actually runs
        Should -Invoke Get-ChildItem -Times 0 -Exactly                          (unscoped)
        Should -Invoke -ModuleName PowerShellBuild Get-ChildItem -Times 1 -Exactly

Standing in a valid code-signing certificate for what the store would return, and keeping the test's own assertion verbatim:

[-] C: the same test fails when the store holds a code-signing certificate
   Expected $null or empty, but got @{Subject=CN=Real Workstation Cert; ...}.
   at $cert | Should -BeNullOrEmpty

Three tests fail that way on a machine with a code-signing certificate in Cert:\CurrentUser\My: Returns $null when no valid certificate is found, Filters out expired certificates, and Filters out certificates without a private key. All three assert $cert | Should -BeNullOrEmpty after asking the real store for a real certificate.

The other three unscoped mocks are harmless by luck rather than design. The two Auto-mode tests assert on the first verbose record, which is written before the store is touched, and the thumbprint test asks for 'NOTFOUND123', which no real certificate matches.

The same fix closes a coverage gap

Get-PSBuildCertificate's post-load validation block has never executed. It is the block that gates a certificate loaded from EnvVar or PfxFile on having a private key, not being expired, and carrying the Code Signing extended key usage:

if ($cert -and -not $SkipValidation -and ($resolvedSource -eq 'EnvVar' -or $resolvedSource -eq 'PfxFile')) {
    if (-not $cert.HasPrivateKey) { throw ... }
    if ($cert.NotAfter -le (Get-Date)) { throw ... }
    $hasCodeSigningEku = $cert.EnhancedKeyUsageList | Where-Object { $_.ObjectId -eq $codeSigningOid }
    if (-not $hasCodeSigningEku) { throw ... }

Every EnvVar and PfxFile test in the file feeds the function deliberately invalid data — [byte[]]@(1,2,3,4,5) base64-encoded, or an empty file with a .pfx extension — so construction of the X509Certificate2 throws before the block is reached. Nothing produces a certificate object that survives to line 228.

Code coverage over Get-PSBuildCertificate.ps1 with the whole file running:

26 tests passed, 0 failed
65 of 95 commands covered

Missed, lines 230-246: the entire private key, expiry, and Code Signing EKU block

This is the validation that decides whether a certificate a consumer supplied through a CI secret is usable. It has never run.

Why it matters

  • The three store tests are latent CI-versus-workstation failures. They are green on the hosted runners and on any workstation without a signing certificate, and red on exactly the machine most likely to have one: a maintainer's, where signing is actually being worked on. That is the worst place for a test to first go red, because the natural reading is "my machine is broken", not "the test was never testing anything".
  • The three store tests assert nothing about the module either way. With the mock inert, Filters out expired certificates and Filters out certificates without a private key are the same test as Returns $null when no valid certificate is found: three identical assertions that the real store happens to be empty. Neither filter is exercised.
  • The EKU and expiry checks are the signing feature's safety net, and the failure mode if they regress is a build that signs with a certificate it should have refused, or throws on one it should have accepted.

The model to follow is already in the file

The SkipValidation for store-backed sources context added in #194 is written correctly:

Mock -ModuleName PowerShellBuild -CommandName Get-ChildItem -MockWith { $script:expiredCertificate }

Nine tests there control what the store returns, and they are the only tests in the file that actually exercise the selection logic. Whatever is done here should look like those.

Options

  1. Add -ModuleName 'PowerShellBuild' to the six mocks and give the three store tests real fixtures. Cheapest change, and it makes Filters out expired certificates and Filters out certificates without a private key do what their names say by returning an expired and a keyless certificate respectively. It also makes them deterministic everywhere.
  2. Cover the post-load validation block by generating a real certificate. New-SelfSignedCertificate -Type CodeSigningCert produces one with a private key and the Code Signing EKU, and it can be exported to a PFX under $TestDrive to drive the PfxFile source, or base64-encoded into an environment variable to drive EnvVar. Windows-only, and it writes to the certificate store, so it needs a cleanup block. A certificate with -NotAfter in the past covers the expiry throw, and one created without the code-signing type covers the EKU throw.
  3. Cover the validation block with a fake object instead. The block only reads HasPrivateKey, NotAfter, Subject, and EnhancedKeyUsageList, so a PSCustomObject would exercise every branch — but only if the loading step can be intercepted, and it constructs X509Certificate2 directly rather than through a mockable command. That would mean extracting the load behind a private function first.
  4. Delete the three store tests that assert the machine has no certificate. Honest about what they currently test, and it removes the latent failure without pretending to add coverage. Worth considering only if (1) is not taken.

(1) and (2) together are the whole fix, and (1) alone is worth doing even if (2) is deferred, because it removes the machine dependency.

Related: #194 (the correctly scoped context), #197 (the platform guard on these same tests). Shares its mock-scoping defect and its remediation with #217, which has the same unscoped Mock problem in tests/Invoke-PSBuildModuleSigning.tests.ps1.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions