diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayConfigPathRulesTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayConfigPathRulesTests.cs new file mode 100644 index 0000000..085d4ac --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayConfigPathRulesTests.cs @@ -0,0 +1,122 @@ +using ZB.MOM.WW.Configuration; +using ZB.MOM.WW.MxGateway.Server.Configuration; + +namespace ZB.MOM.WW.MxGateway.Tests.Configuration; + +/// +/// Direct tests of the shared path-validation primitives so the rules are pinned independently of +/// the options validators that call them. +/// +public sealed class GatewayConfigPathRulesTests +{ + private const string Message = "path rule failed"; + + private static readonly string Root = Path.Combine(Path.GetTempPath(), "mxgw-app"); + + /// Verifies blank values are ignored by every rule. + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void BlankValue_RecordsNoFailure(string? value) + { + ValidationBuilder builder = new(); + + GatewayConfigPathRules.AddIfNotRooted(value, Message, builder); + GatewayConfigPathRules.AddIfInvalidPath(value, Message, builder); + GatewayConfigPathRules.AddIfUnderContentRoot(value, Root, Message, builder); + + Assert.True(builder.IsValid); + } + + /// Verifies a relative path is rejected as not rooted. + [Fact] + public void AddIfNotRooted_RecordsFailure_ForRelativePath() + { + ValidationBuilder builder = new(); + + GatewayConfigPathRules.AddIfNotRooted(Path.Combine("data", "auth.db"), Message, builder); + + Assert.Equal([Message], builder.Failures); + } + + /// Verifies a rooted path passes. + [Fact] + public void AddIfNotRooted_Passes_ForRootedPath() + { + ValidationBuilder builder = new(); + + GatewayConfigPathRules.AddIfNotRooted(Path.Combine(Root, "auth.db"), Message, builder); + + Assert.True(builder.IsValid); + } + + /// Verifies a NUL character makes the path invalid. + [Fact] + public void AddIfInvalidPath_RecordsFailure_ForEmbeddedNul() + { + ValidationBuilder builder = new(); + + GatewayConfigPathRules.AddIfInvalidPath("a\0b", Message, builder); + + Assert.Equal([Message], builder.Failures); + } + + /// Verifies an ordinary path is valid. + [Fact] + public void AddIfInvalidPath_Passes_ForOrdinaryPath() + { + ValidationBuilder builder = new(); + + GatewayConfigPathRules.AddIfInvalidPath(Path.Combine(Root, "auth.db"), Message, builder); + + Assert.True(builder.IsValid); + } + + /// Verifies paths inside the content root, and the root itself, are rejected. + [Fact] + public void AddIfUnderContentRoot_RecordsFailure_ForPathInsideRoot() + { + ValidationBuilder inside = new(); + ValidationBuilder equal = new(); + ValidationBuilder trailing = new(); + + GatewayConfigPathRules.AddIfUnderContentRoot(Path.Combine(Root, "sub", "auth.db"), Root, Message, inside); + GatewayConfigPathRules.AddIfUnderContentRoot(Root, Root, Message, equal); + GatewayConfigPathRules.AddIfUnderContentRoot( + Path.Combine(Root, "auth.db"), + Root + Path.DirectorySeparatorChar, + Message, + trailing); + + Assert.Equal([Message], inside.Failures); + Assert.Equal([Message], equal.Failures); + Assert.Equal([Message], trailing.Failures); + } + + /// Verifies a sibling directory sharing the root's name prefix is not treated as inside it. + [Fact] + public void AddIfUnderContentRoot_Passes_ForSiblingWithSharedPrefix() + { + ValidationBuilder builder = new(); + + GatewayConfigPathRules.AddIfUnderContentRoot( + Root + "-data" + Path.DirectorySeparatorChar + "auth.db", + Root, + Message, + builder); + + Assert.True(builder.IsValid); + } + + /// Verifies a malformed value reports nothing here, leaving it to the path-validity rule. + [Fact] + public void AddIfUnderContentRoot_Passes_ForMalformedValue() + { + ValidationBuilder builder = new(); + + GatewayConfigPathRules.AddIfUnderContentRoot("a\0b", Root, Message, builder); + + Assert.True(builder.IsValid); + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/DashboardRedactorTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/DashboardRedactorTests.cs new file mode 100644 index 0000000..197486d --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/DashboardRedactorTests.cs @@ -0,0 +1,51 @@ +using ZB.MOM.WW.MxGateway.Server.Dashboard; +using ZB.MOM.WW.MxGateway.Server.Diagnostics; + +namespace ZB.MOM.WW.MxGateway.Tests.Dashboard; + +/// Tests for . +public sealed class DashboardRedactorTests +{ + /// Verifies null, empty and whitespace values pass through unchanged. + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Redact_ReturnsBlankValueUnchanged(string? value) + { + Assert.Equal(value, DashboardRedactor.Redact(value)); + } + + /// Verifies harmless text is returned unchanged. + [Fact] + public void Redact_ReturnsOrdinaryTextUnchanged() + { + Assert.Equal("Session opened for Plant1", DashboardRedactor.Redact("Session opened for Plant1")); + } + + /// Verifies text containing a sensitive marker is replaced whole, case-insensitively. + [Theory] + [InlineData("apikey=abc")] + [InlineData("API_KEY abc")] + [InlineData("Authorization: Basic abc")] + [InlineData("user credential rejected")] + [InlineData("bad Password supplied")] + [InlineData("client SECRET")] + [InlineData("hub Token minted")] + public void Redact_ReplacesTextContainingSensitiveMarker(string value) + { + Assert.Equal(GatewayLogRedactor.RedactedValue, DashboardRedactor.Redact(value)); + } + + /// Verifies a gateway API key goes through client-identity redaction and its secret never survives. + [Fact] + public void Redact_DelegatesGatewayKeysToClientIdentityRedaction() + { + const string Value = "Bearer mxgw_dev_topsecretvalue"; + + string? result = DashboardRedactor.Redact(Value); + + Assert.Equal(GatewayLogRedactor.RedactClientIdentity(Value), result); + Assert.DoesNotContain("topsecretvalue", result); + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Sessions/EventSubscriberReplayAttachmentTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Sessions/EventSubscriberReplayAttachmentTests.cs new file mode 100644 index 0000000..47caabd --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Sessions/EventSubscriberReplayAttachmentTests.cs @@ -0,0 +1,63 @@ +using System.Threading.Channels; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Server.Sessions; + +namespace ZB.MOM.WW.MxGateway.Tests.Sessions; + +/// Tests for the value shape. +public sealed class EventSubscriberReplayAttachmentTests +{ + /// Verifies the constructor exposes every component unchanged. + [Fact] + public void Constructor_ExposesComponents() + { + StubLease lease = new(); + MxEvent[] replay = [new MxEvent { WorkerSequence = 5 }, new MxEvent { WorkerSequence = 6 }]; + + EventSubscriberReplayAttachment attachment = new(lease, replay, true, 5, 6); + + Assert.Same(lease, attachment.Lease); + Assert.Same(replay, attachment.ReplayedEvents); + Assert.True(attachment.Gap); + Assert.Equal(5UL, attachment.OldestAvailableSequence); + Assert.Equal(6UL, attachment.LiveResumeSequence); + } + + /// Verifies value equality holds for identical components and breaks when a watermark differs. + [Fact] + public void Equality_ComparesComponents() + { + StubLease lease = new(); + IReadOnlyList replay = []; + + EventSubscriberReplayAttachment a = new(lease, replay, false, 0, 10); + EventSubscriberReplayAttachment same = new(lease, replay, false, 0, 10); + EventSubscriberReplayAttachment other = a with { LiveResumeSequence = 11 }; + + Assert.Equal(a, same); + Assert.NotEqual(a, other); + } + + /// Verifies disposing the lease through the attachment releases the subscriber. + [Fact] + public void Lease_DisposeIsObservableThroughAttachment() + { + StubLease lease = new(); + EventSubscriberReplayAttachment attachment = new(lease, [], false, 0, 0); + + attachment.Lease.Dispose(); + + Assert.True(lease.Disposed); + } + + private sealed class StubLease : IEventSubscriberLease + { + private readonly Channel channel = Channel.CreateUnbounded(); + + public bool Disposed { get; private set; } + + public ChannelReader Reader => channel.Reader; + + public void Dispose() => Disposed = true; + } +}