Files
natsnet/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ServerTests.cs
2026-02-28 12:12:50 -05:00

496 lines
18 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 0910: 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;
using ZB.MOM.NatsNet.Server.Internal;
namespace ZB.MOM.NatsNet.Server.Tests;
/// <summary>
/// Standalone unit tests for <see cref="NatsServer"/> helpers.
/// Tests that require a running server (listener, TLS, cluster) are marked n/a
/// and will be ported in sessions 1023.
/// </summary>
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 29762982
// =========================================================================
[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<InvalidOperationException>(
() => 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();
}
// =============================================================================
// Session 10: Listeners, INFO JSON, TLS helpers, GetRandomIP
// =============================================================================
/// <summary>
/// Tests for session 10 features: GenerateInfoJson, TlsVersion helpers,
/// CopyInfo, and GetRandomIP.
/// </summary>
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 30793080)
// =========================================================================
[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<INetResolver>();
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<INetResolver>();
resolver.LookupHostAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.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<INetResolver>();
resolver.LookupHostAsync("localhost", Arg.Any<CancellationToken>())
.Returns(Task.FromResult(Array.Empty<string>()));
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<INetResolver>();
resolver.LookupHostAsync("localhost", Arg.Any<CancellationToken>())
.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<INetResolver>();
resolver.LookupHostAsync("localhost", Arg.Any<CancellationToken>())
.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<INetResolver>();
resolver.LookupHostAsync("localhost", Arg.Any<CancellationToken>())
.Returns(Task.FromResult(new[] { "1.2.3.4", "2.2.3.4", "3.2.3.4" }));
var excluded = new HashSet<string> { "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");
}
}
}