// Copyright 2012-2026 The NATS Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // Adapted from server/server_test.go in the NATS server Go source. // Session 09–10: standalone unit tests for NatsServer helpers. using System.Text; using System.Text.Json; using System.Text.RegularExpressions; using NSubstitute; using NSubstitute.ExceptionExtensions; using Shouldly; using Xunit; using ZB.MOM.NatsNet.Server.Auth; namespace ZB.MOM.NatsNet.Server.Tests; /// /// Standalone unit tests for helpers. /// Tests that require a running server (listener, TLS, cluster) are marked n/a /// and will be ported in sessions 10–23. /// public sealed class ServerTests { [Fact] public void Init_CalledMultipleTimes_RemainsIdempotent() { var before = ServerConstants.GitCommit; ServerConstants.Init(); ServerConstants.Init(); ServerConstants.GitCommit.ShouldBe(before); } // ========================================================================= // TestSemanticVersion — Test ID 2866 // Validates that ServerConstants.Version matches semver format. // Mirrors Go TestSemanticVersion in server/server_test.go. // ========================================================================= [Fact] public void Version_IsValidSemVer() { // SemVer regex: major.minor.patch with optional pre-release / build meta. var semVerRe = new Regex(@"^\d+\.\d+\.\d+(-\S+)?(\+\S+)?$", RegexOptions.Compiled); semVerRe.IsMatch(ServerConstants.Version).ShouldBeTrue( $"Version ({ServerConstants.Version}) is not a valid SemVer string"); } // ========================================================================= // TestProcessCommandLineArgs — Test ID 2882 // Tests the ProcessCommandLineArgs helper. // The Go version uses flag.FlagSet; our C# port takes string[]. // Mirrors Go TestProcessCommandLineArgs in server/server_test.go. // ========================================================================= [Fact] public void ProcessCommandLineArgs_NoArgs_ReturnsFalse() { var (showVersion, showHelp, err) = NatsServer.ProcessCommandLineArgs([]); err.ShouldBeNull(); showVersion.ShouldBeFalse(); showHelp.ShouldBeFalse(); } [Theory] [InlineData("version", true, false)] [InlineData("VERSION", true, false)] [InlineData("help", false, true)] [InlineData("HELP", false, true)] public void ProcessCommandLineArgs_KnownSubcommand_ReturnsCorrectFlags( string arg, bool wantVersion, bool wantHelp) { var (showVersion, showHelp, err) = NatsServer.ProcessCommandLineArgs([arg]); err.ShouldBeNull(); showVersion.ShouldBe(wantVersion); showHelp.ShouldBe(wantHelp); } [Fact] public void ProcessCommandLineArgs_UnknownSubcommand_ReturnsError() { var (_, _, err) = NatsServer.ProcessCommandLineArgs(["foo"]); err.ShouldNotBeNull(); } // ========================================================================= // CompressionMode helpers — standalone tests for features 2976–2982 // ========================================================================= [Theory] [InlineData("off", CompressionMode.Off)] [InlineData("false", CompressionMode.Off)] [InlineData("accept", CompressionMode.Accept)] [InlineData("s2_fast", CompressionMode.S2Fast)] [InlineData("fast", CompressionMode.S2Fast)] [InlineData("better", CompressionMode.S2Better)] [InlineData("best", CompressionMode.S2Best)] public void ValidateAndNormalizeCompressionOption_KnownModes_NormalizesCorrectly( string input, string expected) { var co = new CompressionOpts { Mode = input }; NatsServer.ValidateAndNormalizeCompressionOption(co, CompressionMode.S2Fast); co.Mode.ShouldBe(expected); } [Fact] public void ValidateAndNormalizeCompressionOption_OnAlias_MapsToChosenMode() { var co = new CompressionOpts { Mode = "on" }; NatsServer.ValidateAndNormalizeCompressionOption(co, CompressionMode.S2Better); co.Mode.ShouldBe(CompressionMode.S2Better); } [Fact] public void ValidateAndNormalizeCompressionOption_S2Auto_UsesDefaults_WhenNoThresholds() { var co = new CompressionOpts { Mode = "s2_auto" }; NatsServer.ValidateAndNormalizeCompressionOption(co, CompressionMode.S2Fast); co.Mode.ShouldBe(CompressionMode.S2Auto); co.RttThresholds.ShouldBe(NatsServer.DefaultCompressionS2AutoRttThresholds.ToList()); } [Fact] public void ValidateAndNormalizeCompressionOption_UnsupportedMode_Throws() { var co = new CompressionOpts { Mode = "bogus" }; Should.Throw( () => NatsServer.ValidateAndNormalizeCompressionOption(co, CompressionMode.S2Fast)); } [Theory] [InlineData(5, CompressionMode.S2Uncompressed)] // <= 10 ms threshold [InlineData(25, CompressionMode.S2Fast)] // 10 < rtt <= 50 ms [InlineData(75, CompressionMode.S2Better)] // 50 < rtt <= 100 ms [InlineData(150, CompressionMode.S2Best)] // > 100 ms public void SelectS2AutoModeBasedOnRtt_DefaultThresholds_CorrectMode(int rttMs, string expected) { var result = NatsServer.SelectS2AutoModeBasedOnRtt( TimeSpan.FromMilliseconds(rttMs), NatsServer.DefaultCompressionS2AutoRttThresholds); result.ShouldBe(expected); } [Theory] [InlineData(CompressionMode.Off, CompressionMode.S2Fast, CompressionMode.Off)] [InlineData(CompressionMode.Accept, CompressionMode.Accept, CompressionMode.Off)] [InlineData(CompressionMode.S2Fast, CompressionMode.Accept, CompressionMode.S2Fast)] [InlineData(CompressionMode.Accept, CompressionMode.S2Fast, CompressionMode.S2Fast)] [InlineData(CompressionMode.Accept, CompressionMode.S2Auto, CompressionMode.S2Fast)] public void SelectCompressionMode_TableDriven(string local, string remote, string expected) { NatsServer.SelectCompressionMode(local, remote).ShouldBe(expected); } [Fact] public void SelectCompressionMode_RemoteNotSupported_ReturnsNotSupported() { NatsServer.SelectCompressionMode(CompressionMode.S2Fast, CompressionMode.NotSupported) .ShouldBe(CompressionMode.NotSupported); } [Fact] public void CompressOptsEqual_SameMode_ReturnsTrue() { var c1 = new CompressionOpts { Mode = CompressionMode.S2Fast }; var c2 = new CompressionOpts { Mode = CompressionMode.S2Fast }; NatsServer.CompressOptsEqual(c1, c2).ShouldBeTrue(); } [Fact] public void CompressOptsEqual_DifferentModes_ReturnsFalse() { var c1 = new CompressionOpts { Mode = CompressionMode.S2Fast }; var c2 = new CompressionOpts { Mode = CompressionMode.S2Best }; NatsServer.CompressOptsEqual(c1, c2).ShouldBeFalse(); } // ========================================================================= // Validation helpers // ========================================================================= [Fact] public void ValidateCluster_ClusterNameWithSpaces_ReturnsError() { var opts = new ServerOptions(); opts.Cluster.Name = "bad name"; var err = NatsServer.ValidateCluster(opts); err.ShouldNotBeNull(); err.ShouldBeSameAs(ServerErrors.ErrClusterNameHasSpaces); } [Fact] public void ValidatePinnedCerts_ValidSha256_ReturnsNull() { var pinned = new PinnedCertSet( [new string('a', 64)]); // 64 hex chars var err = NatsServer.ValidatePinnedCerts(pinned); err.ShouldBeNull(); } [Fact] public void ValidatePinnedCerts_InvalidSha256_ReturnsError() { var pinned = new PinnedCertSet(["not_a_sha256"]); var err = NatsServer.ValidatePinnedCerts(pinned); err.ShouldNotBeNull(); } // ========================================================================= // GetServerProto // ========================================================================= [Fact] public void GetServerProto_DefaultOpts_ReturnsMsgTraceProto() { var opts = new ServerOptions(); // SetBaselineOptions so OverrideProto gets default 0. opts.SetBaselineOptions(); var (s, err) = NatsServer.NewServer(opts); err.ShouldBeNull(); s.ShouldNotBeNull(); s!.GetServerProto().ShouldBe(ServerProtocol.MsgTraceProto); } // ========================================================================= // Account helpers // ========================================================================= [Fact] public void ComputeRoutePoolIdx_PoolSizeOne_AlwaysReturnsZero() { NatsServer.ComputeRoutePoolIdx(1, "any-account").ShouldBe(0); NatsServer.ComputeRoutePoolIdx(0, "any-account").ShouldBe(0); } [Fact] public void ComputeRoutePoolIdx_PoolSizeN_ReturnsIndexInRange() { const int poolSize = 5; var idx = NatsServer.ComputeRoutePoolIdx(poolSize, "my-account"); idx.ShouldBeInRange(0, poolSize - 1); } [Fact] public void NeedsCompression_Empty_ReturnsFalse() => NatsServer.NeedsCompression(string.Empty).ShouldBeFalse(); [Fact] public void NeedsCompression_Off_ReturnsFalse() => NatsServer.NeedsCompression(CompressionMode.Off).ShouldBeFalse(); [Fact] public void NeedsCompression_S2Fast_ReturnsTrue() => NatsServer.NeedsCompression(CompressionMode.S2Fast).ShouldBeTrue(); } // ============================================================================= // Session 10: Listeners, INFO JSON, TLS helpers, GetRandomIP // ============================================================================= /// /// Tests for session 10 features: GenerateInfoJson, TlsVersion helpers, /// CopyInfo, and GetRandomIP. /// public sealed class ServerListenersTests { // ========================================================================= // GenerateInfoJson (feature 3069) — Test ID 2906 // Mirrors Go TestServerJsonMarshalNestedStructsPanic (guards against // marshaller panics with nested/nullable structs). // ========================================================================= [Fact] public void GenerateInfoJson_MinimalInfo_ProducesInfoFrame() { var info = new ServerInfo { Id = "TEST", Version = "1.0.0", Host = "0.0.0.0", Port = 4222 }; var bytes = NatsServer.GenerateInfoJson(info); var text = Encoding.UTF8.GetString(bytes); text.ShouldStartWith("INFO {"); text.ShouldEndWith("}\r\n"); } [Fact] public void GenerateInfoJson_WithConnectUrls_IncludesUrls() { var info = new ServerInfo { Id = "TEST", Version = "1.0.0", ClientConnectUrls = ["nats://127.0.0.1:4222", "nats://127.0.0.1:4223"], }; var text = Encoding.UTF8.GetString(NatsServer.GenerateInfoJson(info)); text.ShouldContain("connect_urls"); text.ShouldContain("4222"); } [Fact] public void GenerateInfoJson_WithSubjectPermissions_DoesNotThrow() { // Mirrors Go TestServerJsonMarshalNestedStructsPanic — guards against // JSON marshaller failures with nested nullable structs. var info = new ServerInfo { Id = "TEST", Version = "1.0.0", Import = new SubjectPermission { Allow = ["foo.>"], Deny = ["bar"] }, Export = new SubjectPermission { Allow = ["pub.>"] }, }; var bytes = NatsServer.GenerateInfoJson(info); bytes.ShouldNotBeEmpty(); // Strip the "INFO " prefix (5 bytes) and the trailing "\r\n" (2 bytes) to get pure JSON. var json = Encoding.UTF8.GetString(bytes, 5, bytes.Length - 7); var doc = JsonDocument.Parse(json); doc.RootElement.GetProperty("import").ValueKind.ShouldBe(JsonValueKind.Object); } // ========================================================================= // TlsVersion helpers (features 3079–3080) // ========================================================================= [Theory] [InlineData(0x0301u, "1.0")] [InlineData(0x0302u, "1.1")] [InlineData(0x0303u, "1.2")] [InlineData(0x0304u, "1.3")] public void TlsVersion_KnownCodes_ReturnsVersionString(uint ver, string expected) => NatsServer.TlsVersion((ushort)ver).ShouldBe(expected); [Fact] public void TlsVersion_UnknownCode_ReturnsUnknownLabel() => NatsServer.TlsVersion(0xFFFF).ShouldStartWith("Unknown"); [Theory] [InlineData("1.0", (ushort)0x0301)] [InlineData("1.1", (ushort)0x0302)] [InlineData("1.2", (ushort)0x0303)] [InlineData("1.3", (ushort)0x0304)] public void TlsVersionFromString_KnownStrings_ReturnsCode(string input, ushort expected) { var (ver, err) = NatsServer.TlsVersionFromString(input); err.ShouldBeNull(); ver.ShouldBe(expected); } [Fact] public void TlsVersionFromString_UnknownString_ReturnsError() { var (_, err) = NatsServer.TlsVersionFromString("9.9"); err.ShouldNotBeNull(); } // ========================================================================= // CopyInfo (feature 3069) // ========================================================================= [Fact] public void CopyInfo_DeepCopiesSlices() { var (s, err) = NatsServer.NewServer(new ServerOptions()); err.ShouldBeNull(); s.ShouldNotBeNull(); s!._info.ClientConnectUrls = ["nats://127.0.0.1:4222"]; var copy = s.CopyInfo(); copy.ClientConnectUrls.ShouldNotBeNull(); copy.ClientConnectUrls!.ShouldBe(["nats://127.0.0.1:4222"]); // Mutating original slice shouldn't affect the copy. s._info.ClientConnectUrls = ["nats://10.0.0.1:4222"]; copy.ClientConnectUrls[0].ShouldBe("nats://127.0.0.1:4222"); } // ========================================================================= // GetRandomIP (feature 3141) — Test ID 2895 // Mirrors Go TestGetRandomIP. // ========================================================================= private static NatsServer MakeServer() { var (s, err) = NatsServer.NewServer(new ServerOptions()); err.ShouldBeNull(); return s!; } [Fact] public async Task GetRandomIP_NoPort_ReturnsFormatError() { var s = MakeServer(); var resolver = Substitute.For(); var (_, err) = await s.GetRandomIP(resolver, "noport"); err.ShouldNotBeNull(); err!.Message.ShouldContain("port"); } [Fact] public async Task GetRandomIP_ResolverThrows_PropagatesError() { var s = MakeServer(); var resolver = Substitute.For(); resolver.LookupHostAsync(Arg.Any(), Arg.Any()) .ThrowsAsync(new InvalidOperationException("on purpose")); var (_, err) = await s.GetRandomIP(resolver, "localhost:4222"); err.ShouldNotBeNull(); err!.Message.ShouldContain("on purpose"); } [Fact] public async Task GetRandomIP_EmptyIps_ReturnsFallbackUrl() { var s = MakeServer(); var resolver = Substitute.For(); resolver.LookupHostAsync("localhost", Arg.Any()) .Returns(Task.FromResult(Array.Empty())); var (addr, err) = await s.GetRandomIP(resolver, "localhost:4222"); err.ShouldBeNull(); addr.ShouldBe("localhost:4222"); } [Fact] public async Task GetRandomIP_SingleIp_ReturnsMappedAddress() { var s = MakeServer(); var resolver = Substitute.For(); resolver.LookupHostAsync("localhost", Arg.Any()) .Returns(Task.FromResult(new[] { "1.2.3.4" })); var (addr, err) = await s.GetRandomIP(resolver, "localhost:4222"); err.ShouldBeNull(); addr.ShouldBe("1.2.3.4:4222"); } [Fact] public async Task GetRandomIP_MultipleIps_AllSelectedWithinRange() { var s = MakeServer(); var resolver = Substitute.For(); resolver.LookupHostAsync("localhost", Arg.Any()) .Returns(Task.FromResult(new[] { "1.2.3.4", "2.2.3.4", "3.2.3.4" })); var dist = new int[3]; for (int i = 0; i < 100; i++) { var (addr, err) = await s.GetRandomIP(resolver, "localhost:4222"); err.ShouldBeNull(); dist[int.Parse(addr[..1]) - 1]++; } // Each IP should appear at least once and no single IP should dominate. foreach (var d in dist) d.ShouldBeGreaterThan(0); } [Fact] public async Task GetRandomIP_ExcludedIp_NeverReturned() { var s = MakeServer(); var resolver = Substitute.For(); resolver.LookupHostAsync("localhost", Arg.Any()) .Returns(Task.FromResult(new[] { "1.2.3.4", "2.2.3.4", "3.2.3.4" })); var excluded = new HashSet { "1.2.3.4:4222" }; for (int i = 0; i < 100; i++) { var (addr, err) = await s.GetRandomIP(resolver, "localhost:4222", excluded); err.ShouldBeNull(); addr.ShouldNotBe("1.2.3.4:4222"); } } }