Introduces a tests/CryptEscrow.Tests/ project and wires `dotnet test`
into the CI workflow so PRs fail fast before publishing. Scaffolding
and critical-path coverage only; command-level tests are deferred to
a follow-up because they need a mocking layer over BitLocker / WMI.
## Framework choices
xUnit 2.9.2 + FluentAssertions 6.12.1 (pinned to the free version —
7.x moved to commercial) + RichardSzalay.MockHttp 7.0.0. No mocking
framework — code under test is mostly static classes, concrete types,
and file I/O, so ambient-state fixtures + InternalsVisibleTo are
sufficient (and we avoid the Moq SponsorLink controversy).
TargetFramework: net10.0-windows (matches main project — uses
Windows-only APIs like Microsoft.Win32.Registry).
## Test seams in production code (minimal, non-behavioral)
- InternalsVisibleTo("CryptEscrow.Tests") in CryptEscrow.csproj.
- CryptServerClient gains an internal test ctor accepting an
HttpMessageHandler so MockHttp can intercept without spinning up
a real TLS stack. GetClientCertificate promoted from private to
internal so strategy-ordering can be tested directly.
- ConfigService gains two internal static seams:
ConfigPathOverride and RegistryReaderOverride. Both default to
null; production behavior is unchanged.
## Fixtures (tests/CryptEscrow.Tests/Fixtures/)
- EnvironmentSnapshot: scoped env-var save/restore.
- TempRegistryKey: creates HKCU\Software\CryptEscrowTest\<guid>,
installs the reader override, deletes on dispose.
- TempConfigFile: writes YAML to a unique %TEMP% subdir and points
ConfigPathOverride at it.
- SelfSignedCertFactory: generates ephemeral RSA certs via
CertificateRequest and serializes to PEM/key and PFX temp files.
- TransientStoreCert: installs a cert in CurrentUser\My (no admin
needed) and removes it on dispose.
- CredentialManagerWriter: test-only advapi32!CredWriteW helper for
seeding entries that the production reader consumes. Uses DllImport
(not LibraryImport) because the CREDENTIAL struct contains FILETIME
which isn't compatible with DisableRuntimeMarshalling.
## Test coverage (39 tests, ~1s runtime)
- ConfigServiceTests (15): env > registry > YAML > default priority
chain for every GetX() helper including the new PFX/PEM options;
GetAuthConfig composition from mixed sources. Joined to a
GlobalState collection with DisableParallelization = true to prevent
static-state races.
- CredentialManagerTests (6): ASCII round trip, Unicode round trip,
missing target, empty/null/whitespace target guard.
- GetClientCertificateTests (10): strategy priority ordering
(CertStore > PFX > PEM), graceful fall-through on missing files,
CertStoreWinsOverPfxWhenBothConfigured,
FallsThroughToPfxWhenCertStoreThumbprintNotFound.
- CryptServerClientTests (8): checkin success/401/403 (no retry),
form-url-encoded body fields, default and custom API key header,
verify success/401.
## CI workflow
.github/workflows/ci.yml gains Restore, Test, and Upload test results
steps before the publish/package steps. TRX artifact uploaded via
actions/upload-artifact@v4. If tests fail, the workflow short-circuits
before producing an MSI.
## Copilot review resolution (addressed in-PR)
1. Test parallelization — fixed via GlobalState collection.
2. ConfigPathOverride edge cases (empty string, relative path) —
fixed via shared ResolvedOverrideDir helper with IsNullOrWhiteSpace
guards on both the override and its Path.GetDirectoryName result.
3. mTLS strategy ordering contradicted the README (PFX > PEM > store
vs. store preferred in docs). Fixed by reordering code to match
the security ranking. LoadFromCertStore short-circuits when neither
thumbprint nor subject is set, so file-only users pay no cost and
don't get misleading error logs. Added tests to prove the ordering.
4. README strategy ordering — addressed by the code fix; README
already had the correct ordering.
5. AllowUnsafeBlocks — declined. It's required by the LibraryImport
source generator (SYSLIB1062). Removing it breaks the build.
## Out of scope (follow-up PRs)
- Command-level tests for EscrowCommand / RotateCommand /
VerifyCommand (need a mocking abstraction over BitLocker / WMI /
manage-bde).
- Clock seam for retry backoff tests — current Task.Delay is real.
- Coverage threshold enforcement in CI (e.g. coverlet + 80% gate).
- Integration tests against a real Crypt Server.
- REG_DWORD handling bug in ConfigService.GetRegistryValue —
tracked separately in #4.