feat(adminui): CertificateStoreManager — by-thumbprint trust/untrust/delete

This commit is contained in:
Joseph Doherty
2026-06-18 05:04:36 -04:00
parent 77cc39e6a7
commit b47fc10ec0
2 changed files with 318 additions and 0 deletions
@@ -0,0 +1,196 @@
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Certificates;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Certificates;
public sealed class CertificateStoreManagerTests : IDisposable
{
private readonly string _root;
private readonly CertificateStoreManager _sut;
public CertificateStoreManagerTests()
{
_root = Path.Combine(Path.GetTempPath(), $"pki-test-{Guid.NewGuid():N}");
_sut = new CertificateStoreManager(_root);
}
public void Dispose()
{
if (Directory.Exists(_root))
Directory.Delete(_root, recursive: true);
}
// ── Seed helper ──────────────────────────────────────────────────────────
private static string SeedCert(string storeCertsDir, string cn)
{
Directory.CreateDirectory(storeCertsDir);
using var rsa = RSA.Create(2048);
var req = new CertificateRequest(
$"CN={cn}", rsa, HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
using var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1));
var file = Path.Combine(storeCertsDir, $"{cn} [{cert.Thumbprint}].der");
File.WriteAllBytes(file, cert.Export(X509ContentType.Cert));
return cert.Thumbprint!;
}
// ── Assertion helpers ─────────────────────────────────────────────────────
private static bool ThumbprintExistsInDir(string certsDir, string thumbprint)
{
if (!Directory.Exists(certsDir)) return false;
string[] exts = [".der", ".cer", ".crt"];
foreach (var file in Directory.EnumerateFiles(certsDir))
{
if (!exts.Contains(Path.GetExtension(file), StringComparer.OrdinalIgnoreCase)) continue;
try
{
using var cert = X509CertificateLoader.LoadCertificateFromFile(file);
if (string.Equals(cert.Thumbprint, thumbprint, StringComparison.OrdinalIgnoreCase))
return true;
}
catch { /* ignore */ }
}
return false;
}
private static int CertCountInDir(string certsDir)
{
if (!Directory.Exists(certsDir)) return 0;
string[] exts = [".der", ".cer", ".crt"];
int count = 0;
foreach (var file in Directory.EnumerateFiles(certsDir))
{
if (!exts.Contains(Path.GetExtension(file), StringComparer.OrdinalIgnoreCase)) continue;
try
{
using var cert = X509CertificateLoader.LoadCertificateFromFile(file);
count++;
}
catch { /* ignore */ }
}
return count;
}
// ── Tests ─────────────────────────────────────────────────────────────────
[Fact]
public void Trust_MovesRejectedToTrusted()
{
var rejectedCerts = Path.Combine(_root, "rejected", "certs");
var tp = SeedCert(rejectedCerts, "TestClient1");
var result = _sut.Trust(tp);
result.Success.ShouldBeTrue(result.Error);
ThumbprintExistsInDir(rejectedCerts, tp).ShouldBeFalse("cert should have left rejected");
ThumbprintExistsInDir(Path.Combine(_root, "trusted", "certs"), tp).ShouldBeTrue("cert should be in trusted");
}
[Fact]
public void Untrust_MovesTrustedToRejected()
{
var trustedCerts = Path.Combine(_root, "trusted", "certs");
var tp = SeedCert(trustedCerts, "TestClient2");
var result = _sut.Untrust(tp);
result.Success.ShouldBeTrue(result.Error);
ThumbprintExistsInDir(trustedCerts, tp).ShouldBeFalse("cert should have left trusted");
ThumbprintExistsInDir(Path.Combine(_root, "rejected", "certs"), tp).ShouldBeTrue("cert should be in rejected");
}
[Fact]
public void Delete_Rejected_RemovesFile()
{
var rejectedCerts = Path.Combine(_root, "rejected", "certs");
var tp = SeedCert(rejectedCerts, "TestClient3");
var result = _sut.Delete("rejected", tp);
result.Success.ShouldBeTrue(result.Error);
ThumbprintExistsInDir(rejectedCerts, tp).ShouldBeFalse("cert should be gone from rejected");
}
[Fact]
public void Delete_Trusted_RemovesFile()
{
var trustedCerts = Path.Combine(_root, "trusted", "certs");
var tp = SeedCert(trustedCerts, "TestClient4");
var result = _sut.Delete("trusted", tp);
result.Success.ShouldBeTrue(result.Error);
ThumbprintExistsInDir(trustedCerts, tp).ShouldBeFalse("cert should be gone from trusted");
}
[Fact]
public void Trust_UnknownThumbprint_FailsNoChange()
{
var tp = new string('A', 40);
var result = _sut.Trust(tp);
result.Success.ShouldBeFalse();
CertCountInDir(Path.Combine(_root, "trusted", "certs")).ShouldBe(0);
}
[Fact]
public void Trust_PathTraversalThumbprint_Rejected()
{
var result = _sut.Trust("../../x");
result.Success.ShouldBeFalse();
result.Error.ShouldNotBeNull();
result.Error.ShouldContain("invalid", Case.Insensitive);
// Verify no files were created anywhere under root
if (Directory.Exists(_root))
Directory.GetFiles(_root, "*", SearchOption.AllDirectories).ShouldBeEmpty();
}
[Fact]
public void Delete_DisallowedStore_Fails()
{
var ownCerts = Path.Combine(_root, "own", "certs");
var tp = SeedCert(ownCerts, "OwnCert");
var result = _sut.Delete("own", tp);
result.Success.ShouldBeFalse();
result.Error.ShouldNotBeNull();
result.Error.ShouldContain("store", Case.Insensitive);
ThumbprintExistsInDir(ownCerts, tp).ShouldBeTrue("own cert should still be present");
}
[Fact]
public void Trust_AlreadyInDest_IdempotentSuccess()
{
var rejectedCerts = Path.Combine(_root, "rejected", "certs");
var trustedCerts = Path.Combine(_root, "trusted", "certs");
// Seed into rejected, then copy that exact file into trusted so both have identical bytes/thumbprint
var tp = SeedCert(rejectedCerts, "TestClient8");
Directory.CreateDirectory(trustedCerts);
var srcFile = Directory.GetFiles(rejectedCerts).Single();
File.Copy(srcFile, Path.Combine(trustedCerts, Path.GetFileName(srcFile)));
var result = _sut.Trust(tp);
result.Success.ShouldBeTrue(result.Error);
ThumbprintExistsInDir(rejectedCerts, tp).ShouldBeFalse("source (rejected) should no longer have it");
CertCountInDir(trustedCerts).ShouldBe(1, "trusted should still have exactly one copy");
}
[Fact]
public void Trust_NullOrEmptyThumbprint_Fails()
{
var result = _sut.Trust("");
result.Success.ShouldBeFalse();
result.Error.ShouldNotBeNull();
}
}