3eb370d4ea
v2-ci / build (push) Failing after 49s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
307 lines
11 KiB
C#
307 lines
11 KiB
C#
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 RecordingAuditWriter _audit;
|
|
private readonly CertificateStoreManager _sut;
|
|
|
|
public CertificateStoreManagerTests()
|
|
{
|
|
_root = Path.Combine(Path.GetTempPath(), $"pki-test-{Guid.NewGuid():N}");
|
|
_audit = new RecordingAuditWriter();
|
|
_sut = new CertificateStoreManager(_root, _audit);
|
|
}
|
|
|
|
// ── Recording audit-writer fake ───────────────────────────────────────────
|
|
|
|
private sealed class RecordingAuditWriter : ZB.MOM.WW.Audit.IAuditWriter
|
|
{
|
|
public List<ZB.MOM.WW.Audit.AuditEvent> Events { get; } = new();
|
|
|
|
public Task WriteAsync(ZB.MOM.WW.Audit.AuditEvent evt, CancellationToken ct = default)
|
|
{
|
|
Events.Add(evt);
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
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, "tester");
|
|
|
|
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, "tester");
|
|
|
|
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, "tester");
|
|
|
|
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, "tester");
|
|
|
|
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, "tester");
|
|
|
|
result.Success.ShouldBeFalse();
|
|
CertCountInDir(Path.Combine(_root, "trusted", "certs")).ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public void Trust_PathTraversalThumbprint_Rejected()
|
|
{
|
|
var result = _sut.Trust("../../x", "tester");
|
|
|
|
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, "tester");
|
|
|
|
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, "tester");
|
|
|
|
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("", "tester");
|
|
|
|
result.Success.ShouldBeFalse();
|
|
result.Error.ShouldNotBeNull();
|
|
}
|
|
|
|
// ── Audit tests ───────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void Trust_success_writes_one_success_audit_event()
|
|
{
|
|
var rejectedCerts = Path.Combine(_root, "rejected", "certs");
|
|
var tp = SeedCert(rejectedCerts, "AuditTrust");
|
|
|
|
var result = _sut.Trust(tp, "alice");
|
|
|
|
result.Success.ShouldBeTrue(result.Error);
|
|
var evt = _audit.Events.ShouldHaveSingleItem();
|
|
evt.Action.ShouldBe("Trust");
|
|
evt.Outcome.ShouldBe(ZB.MOM.WW.Audit.AuditOutcome.Success);
|
|
evt.SourceNode.ShouldBe(tp);
|
|
evt.Actor.ShouldBe("alice");
|
|
}
|
|
|
|
[Fact]
|
|
public void Untrust_success_writes_one_success_audit_event()
|
|
{
|
|
var trustedCerts = Path.Combine(_root, "trusted", "certs");
|
|
var tp = SeedCert(trustedCerts, "AuditUntrust");
|
|
|
|
var result = _sut.Untrust(tp, "alice");
|
|
|
|
result.Success.ShouldBeTrue(result.Error);
|
|
var evt = _audit.Events.ShouldHaveSingleItem();
|
|
evt.Action.ShouldBe("Untrust");
|
|
evt.Outcome.ShouldBe(ZB.MOM.WW.Audit.AuditOutcome.Success);
|
|
evt.SourceNode.ShouldBe(tp);
|
|
evt.Actor.ShouldBe("alice");
|
|
}
|
|
|
|
[Fact]
|
|
public void Delete_success_writes_one_success_audit_event()
|
|
{
|
|
var rejectedCerts = Path.Combine(_root, "rejected", "certs");
|
|
var tp = SeedCert(rejectedCerts, "AuditDelete");
|
|
|
|
var result = _sut.Delete("rejected", tp, "alice");
|
|
|
|
result.Success.ShouldBeTrue(result.Error);
|
|
var evt = _audit.Events.ShouldHaveSingleItem();
|
|
evt.Action.ShouldBe("Delete");
|
|
evt.Outcome.ShouldBe(ZB.MOM.WW.Audit.AuditOutcome.Success);
|
|
evt.SourceNode.ShouldBe(tp);
|
|
evt.Actor.ShouldBe("alice");
|
|
}
|
|
|
|
[Fact]
|
|
public void Delete_not_found_writes_one_failure_audit_event()
|
|
{
|
|
var absent = new string('0', 40);
|
|
|
|
var result = _sut.Delete("trusted", absent, "bob");
|
|
|
|
result.Success.ShouldBeFalse();
|
|
var evt = _audit.Events.ShouldHaveSingleItem();
|
|
evt.Action.ShouldBe("Delete");
|
|
evt.Outcome.ShouldBe(ZB.MOM.WW.Audit.AuditOutcome.Failure);
|
|
evt.SourceNode.ShouldBe(absent);
|
|
evt.Actor.ShouldBe("bob");
|
|
}
|
|
|
|
[Fact]
|
|
public void Trust_not_found_writes_one_failure_audit_event()
|
|
{
|
|
var absent = new string('0', 40);
|
|
|
|
var result = _sut.Trust(absent, "bob");
|
|
|
|
result.Success.ShouldBeFalse();
|
|
var evt = _audit.Events.ShouldHaveSingleItem();
|
|
evt.Action.ShouldBe("Trust");
|
|
evt.Outcome.ShouldBe(ZB.MOM.WW.Audit.AuditOutcome.Failure);
|
|
evt.SourceNode.ShouldBe(absent);
|
|
evt.Actor.ShouldBe("bob");
|
|
}
|
|
|
|
[Fact]
|
|
public void Untrust_not_found_writes_one_failure_audit_event()
|
|
{
|
|
var absent = new string('0', 40);
|
|
|
|
var result = _sut.Untrust(absent, "erin");
|
|
|
|
result.Success.ShouldBeFalse();
|
|
var evt = _audit.Events.ShouldHaveSingleItem();
|
|
evt.Action.ShouldBe("Untrust");
|
|
evt.Outcome.ShouldBe(ZB.MOM.WW.Audit.AuditOutcome.Failure);
|
|
evt.SourceNode.ShouldBe(absent);
|
|
evt.Actor.ShouldBe("erin");
|
|
}
|
|
}
|