// 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 System.Net.Security;
using System.Threading;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Shouldly;
using Xunit;
using ZB.MOM.NatsNet.Server.Auth;
using ZB.MOM.NatsNet.Server.Internal;
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();
}
[Fact]
public void MatchesPinnedCert_NullPinnedSet_ReturnsTrue()
{
var client = new ClientConnection(ClientKind.Client, nc: new MemoryStream());
client.MatchesPinnedCert(null).ShouldBeTrue();
}
[Fact]
public void MatchesPinnedCert_NoTlsCertificate_ReturnsFalse()
{
var client = new ClientConnection(ClientKind.Client, nc: new MemoryStream());
var pinned = new PinnedCertSet([new string('a', 64)]);
client.MatchesPinnedCert(pinned).ShouldBeFalse();
}
// =========================================================================
// 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();
[Theory]
[InlineData(CompressionMode.S2Uncompressed, "writer_concurrency=1", "writer_uncompressed")]
[InlineData(CompressionMode.S2Best, "writer_concurrency=1", "writer_best_compression")]
[InlineData(CompressionMode.S2Better, "writer_concurrency=1", "writer_better_compression")]
public void S2WriterOptions_KnownModes_ReturnExpectedOptions(
string mode,
string expectedFirst,
string expectedSecond)
{
var options = NatsServer.S2WriterOptions(mode);
options.ShouldNotBeNull();
options!.ShouldBe([expectedFirst, expectedSecond]);
}
[Fact]
public void S2WriterOptions_UnsupportedMode_ReturnsNull()
{
NatsServer.S2WriterOptions(CompressionMode.S2Fast).ShouldBeNull();
}
}
// =============================================================================
// Session 10: Listeners, INFO JSON, TLS helpers, GetRandomIP
// =============================================================================
///
/// Tests for session 10 features: GenerateInfoJson, TlsVersion helpers,
/// CopyInfo, and GetRandomIP.
///
public sealed class ServerListenersTests
{
[Fact]
public void TlsTimeout_IncompleteHandshake_ClosesConnection()
{
var c = new ClientConnection(ClientKind.Client, nc: new MemoryStream());
using var tls = new SslStream(new MemoryStream(), leaveInnerStreamOpen: false);
c.IsClosed().ShouldBeFalse();
NatsServer.TlsTimeout(c, tls);
c.IsClosed().ShouldBeTrue();
}
[Fact]
public void StartGoRoutine_WithLabels_InvokesSetGoRoutineLabels()
{
var (s, err) = NatsServer.NewServer(new ServerOptions());
err.ShouldBeNull();
s.ShouldNotBeNull();
s!.Start();
var signal = new ManualResetEventSlim(false);
IReadOnlyList>? observed = null;
NatsServer.SetGoRoutineLabelsHookForTest = labels =>
{
observed = labels;
signal.Set();
};
try
{
var started = s.StartGoRoutine(
() => { },
new Dictionary { ["component"] = "server", ["loop"] = "tls" });
started.ShouldBeTrue();
signal.Wait(TimeSpan.FromSeconds(2)).ShouldBeTrue();
observed.ShouldNotBeNull();
observed!.ShouldContain(kv => kv.Key == "component" && kv.Value == "server");
observed.ShouldContain(kv => kv.Key == "loop" && kv.Value == "tls");
}
finally
{
NatsServer.SetGoRoutineLabelsHookForTest = null;
s.Shutdown();
s.WaitForShutdown();
}
}
// =========================================================================
// 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");
}
}
}