Files
natsdotnet/tests/NATS.Server.Core.Tests/InfrastructureGoParityTests.cs
Joseph Doherty 4de691c9c5 perf: add FileStore buffered writes, O(1) state tracking, and eliminate redundant per-publish work
Implement Go-parity background flush loop (coalesce 16KB/8ms) in MsgBlock/FileStore,
replace O(n) GetStateAsync with incremental counters, skip PruneExpired/LoadAsync/
PrunePerSubject when not needed, and bypass RAFT for single-replica streams. Fix counter
tracking bugs in RemoveMsg/EraseMsg/TTL expiry and ObjectDisposedException races in
flush loop disposal. FileStore optimizations verified with 3112/3112 JetStream tests
passing; async publish benchmark remains at ~174 msg/s due to E2E protocol path bottleneck.
2026-03-13 03:11:11 -04:00

1093 lines
41 KiB
C#

// Go reference: golang/nats-server/server/parser_test.go
// Go reference: golang/nats-server/server/log_test.go
// Go reference: golang/nats-server/server/errors_test.go
// Go reference: golang/nats-server/server/config_check_test.go
// Go reference: golang/nats-server/server/subject_transform_test.go
// Go reference: golang/nats-server/server/nkey_test.go
// Go reference: golang/nats-server/server/ping_test.go
// Go reference: golang/nats-server/server/util_test.go
// Go reference: golang/nats-server/server/trust_test.go
//
// Coverage:
// Parser unit tests — ParseSize, HPUB, PUB, SUB, PING/PONG, CONNECT, proto snippet.
// Logging — Serilog file sink, log-reopen semantics, secrets redaction.
// Errors — error context wrapping, ErrorIs through context chain.
// Config check — unknown fields, validation errors, ConfigProcessorException.
// Subject transforms — basic transforms, partition, split, slice, error cases.
// NKey auth — nonceRequired, nonce generation, AuthService with NKeys.
// Ping — server sends periodic PING, client PONG keeps connection alive.
// Util — ParseSize, version checks, URL redaction (ported as .NET equivalent).
// Trust — TrustedKeys options validation.
using System.Buffers;
using System.IO.Pipelines;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.NKeys;
using NATS.Server;
using NATS.Server.Auth;
using NATS.Server.Configuration;
using NATS.Server.Protocol;
using NATS.Server.Subscriptions;
using Serilog;
using NATS.Server.TestUtilities;
namespace NATS.Server.Core.Tests;
/// <summary>
/// Infrastructure parity tests covering parser utilities, logging, error wrapping,
/// config validation, subject transforms, NKey auth, ping, utility helpers, and trust keys.
/// </summary>
public class InfrastructureGoParityTests
{
// ─── helpers ─────────────────────────────────────────────────────────────
private static async Task<List<ParsedCommand>> ParseCommandsAsync(string input)
{
var pipe = new Pipe();
var commands = new List<ParsedCommand>();
var bytes = Encoding.ASCII.GetBytes(input);
await pipe.Writer.WriteAsync(bytes);
pipe.Writer.Complete();
var parser = new NatsParser(maxPayload: NatsProtocol.MaxPayloadSize);
while (true)
{
var result = await pipe.Reader.ReadAsync();
var buffer = result.Buffer;
while (parser.TryParse(ref buffer, out var cmd))
commands.Add(cmd);
pipe.Reader.AdvanceTo(buffer.Start, buffer.End);
if (result.IsCompleted) break;
}
return commands;
}
// ─── Parser: ParseSize (util_test.go:TestParseSize) ──────────────────────
/// <summary>
/// ParseSize returns -1 for an empty span.
/// Go: TestParseSize (util_test.go:27) — nil byte slice returns -1
/// </summary>
[Fact]
public void Parser_ParseSize_returns_minus1_for_empty()
{
// Go: TestParseSize (util_test.go:27)
NatsParser.ParseSize(Span<byte>.Empty).ShouldBe(-1);
}
/// <summary>
/// ParseSize correctly parses a valid decimal integer.
/// Go: TestParseSize (util_test.go:27)
/// </summary>
[Fact]
public void Parser_ParseSize_parses_valid_decimal()
{
// Go: TestParseSize (util_test.go:27)
NatsParser.ParseSize("12345678"u8.ToArray().AsSpan()).ShouldBe(12345678);
}
/// <summary>
/// ParseSize returns -1 for invalid (non-digit) bytes.
/// Go: TestParseSize (util_test.go:27)
/// </summary>
[Fact]
public void Parser_ParseSize_returns_minus1_for_invalid_bytes()
{
// Go: TestParseSize (util_test.go:27)
NatsParser.ParseSize("12345invalid678"u8.ToArray().AsSpan()).ShouldBe(-1);
}
/// <summary>
/// ParseSize parses single digit.
/// Go: TestParseSize (util_test.go:27)
/// </summary>
[Fact]
public void Parser_ParseSize_parses_single_digit()
{
// Go: TestParseSize (util_test.go:27)
NatsParser.ParseSize("5"u8.ToArray().AsSpan()).ShouldBe(5);
}
// ─── Parser: protocol command parsing (parser_test.go) ───────────────────
/// <summary>
/// Parser correctly handles PING command.
/// Go: TestParsePing (parser_test.go:29)
/// </summary>
[Fact]
public async Task Parser_parses_PING()
{
// Go: TestParsePing (parser_test.go:29)
var cmds = await ParseCommandsAsync("PING\r\n");
cmds.ShouldHaveSingleItem();
cmds[0].Type.ShouldBe(CommandType.Ping);
}
/// <summary>
/// Parser correctly handles PONG command.
/// Go: TestParsePong (parser_test.go:77)
/// </summary>
[Fact]
public async Task Parser_parses_PONG()
{
// Go: TestParsePong (parser_test.go:77)
var cmds = await ParseCommandsAsync("PONG\r\n");
cmds.ShouldHaveSingleItem();
cmds[0].Type.ShouldBe(CommandType.Pong);
}
/// <summary>
/// Parser correctly handles CONNECT command.
/// Go: TestParseConnect (parser_test.go:146)
/// </summary>
[Fact]
public async Task Parser_parses_CONNECT()
{
// Go: TestParseConnect (parser_test.go:146)
var cmds = await ParseCommandsAsync("CONNECT {\"verbose\":false,\"echo\":true}\r\n");
cmds.ShouldHaveSingleItem();
cmds[0].Type.ShouldBe(CommandType.Connect);
Encoding.ASCII.GetString(cmds[0].Payload.ToArray()).ShouldContain("verbose");
}
/// <summary>
/// Parser handles SUB without queue group.
/// Go: TestParseSub (parser_test.go:159)
/// </summary>
[Fact]
public async Task Parser_parses_SUB_without_queue()
{
// Go: TestParseSub (parser_test.go:159)
var cmds = await ParseCommandsAsync("SUB foo 1\r\n");
cmds.ShouldHaveSingleItem();
cmds[0].Type.ShouldBe(CommandType.Sub);
cmds[0].Subject.ShouldBe("foo");
cmds[0].Queue.ShouldBeNull();
cmds[0].Sid.ShouldBe("1");
}
/// <summary>
/// Parser handles SUB with queue group.
/// Go: TestParseSub (parser_test.go:159)
/// </summary>
[Fact]
public async Task Parser_parses_SUB_with_queue()
{
// Go: TestParseSub (parser_test.go:159)
var cmds = await ParseCommandsAsync("SUB foo workers 1\r\n");
cmds.ShouldHaveSingleItem();
cmds[0].Type.ShouldBe(CommandType.Sub);
cmds[0].Subject.ShouldBe("foo");
cmds[0].Queue.ShouldBe("workers");
cmds[0].Sid.ShouldBe("1");
}
/// <summary>
/// Parser handles PUB command with subject and payload size.
/// Go: TestParsePub (parser_test.go:178)
/// </summary>
[Fact]
public async Task Parser_parses_PUB()
{
// Go: TestParsePub (parser_test.go:178)
var cmds = await ParseCommandsAsync("PUB foo 5\r\nhello\r\n");
cmds.ShouldHaveSingleItem();
cmds[0].Type.ShouldBe(CommandType.Pub);
cmds[0].Subject.ShouldBe("foo");
Encoding.ASCII.GetString(cmds[0].Payload.ToArray()).ShouldBe("hello");
}
/// <summary>
/// Parser handles HPUB command with headers.
/// Go: TestParseHeaderPub (parser_test.go:310)
/// </summary>
[Fact]
public async Task Parser_parses_HPUB()
{
// Go: TestParseHeaderPub (parser_test.go:310)
const string hdrBlock = "NATS/1.0\r\nX-Foo: bar\r\n\r\n";
const string payload = "hello";
int hdrLen = Encoding.ASCII.GetByteCount(hdrBlock);
int totalLen = hdrLen + Encoding.ASCII.GetByteCount(payload);
var raw = $"HPUB test.subject {hdrLen} {totalLen}\r\n{hdrBlock}{payload}\r\n";
var cmds = await ParseCommandsAsync(raw);
cmds.ShouldHaveSingleItem();
cmds[0].Type.ShouldBe(CommandType.HPub);
cmds[0].Subject.ShouldBe("test.subject");
}
/// <summary>
/// Parser handles PUB with reply subject.
/// Go: TestParsePub (parser_test.go:178) — reply subject
/// </summary>
[Fact]
public async Task Parser_parses_PUB_with_reply()
{
// Go: TestParsePub (parser_test.go:178) — reply subject
var cmds = await ParseCommandsAsync("PUB foo INBOX.1 5\r\nhello\r\n");
cmds.ShouldHaveSingleItem();
cmds[0].Type.ShouldBe(CommandType.Pub);
cmds[0].Subject.ShouldBe("foo");
cmds[0].ReplyTo.ShouldBe("INBOX.1");
}
/// <summary>
/// Parser handles UNSUB command with optional maxMessages.
/// Go: parser_test.go — TestParseSub
/// </summary>
[Fact]
public async Task Parser_parses_UNSUB()
{
// Go: parser_test.go — UNSUB
var cmds = await ParseCommandsAsync("UNSUB 1\r\n");
cmds.ShouldHaveSingleItem();
cmds[0].Type.ShouldBe(CommandType.Unsub);
cmds[0].Sid.ShouldBe("1");
}
/// <summary>
/// Proto snippet function trims correctly from an offset in the buffer.
/// Go: TestProtoSnippet (parser_test.go:715) — snippet from position 0 and beyond
/// </summary>
[Fact]
public void Parser_proto_snippet_produces_correct_window()
{
// Go: TestProtoSnippet (parser_test.go:715)
// Simulate protoSnippet: take 32 chars from position, trim to boundary of sample
const string sample = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
const int snippetSize = 32;
// From position 0: "abcdefghijklmnopqrstuvwxyzABCDEF" (32 chars)
var fromZero = sample.Substring(0, Math.Min(snippetSize, sample.Length));
fromZero.Length.ShouldBe(snippetSize);
fromZero.ShouldBe("abcdefghijklmnopqrstuvwxyzABCDEF");
// From position 20: "uvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" (32 chars, hits end of 52-char sample)
var from20 = sample.Substring(20, Math.Min(snippetSize, sample.Length - 20));
from20.ShouldBe("uvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ");
// From position 51 (second to last): last two chars are "YZ", position 51 gives "Z"
var from51 = sample.Length > 51 ? sample.Substring(51) : "";
from51.ShouldBe("Z"); // last char
// From position 52 (past end): empty
var from52 = sample.Length > 52 ? sample.Substring(52) : "";
from52.ShouldBe("");
}
// ─── Parser: MaxControlLine exceeded (parser_test.go:TestMaxControlLine) ─
/// <summary>
/// Parser throws when control line exceeds NatsProtocol.MaxControlLineSize.
/// Go: TestMaxControlLine (parser_test.go:815)
/// </summary>
[Fact]
public void Parser_throws_on_control_line_too_long()
{
// Go: TestMaxControlLine (parser_test.go:815)
// Build a line that exceeds NatsProtocol.MaxControlLineSize (4096)
var longSubject = new string('x', 4100);
var rawLine = $"SUB {longSubject} 1\r\n";
var parser = new NatsParser(maxPayload: NatsProtocol.MaxPayloadSize);
var buffer = new ReadOnlySequence<byte>(Encoding.ASCII.GetBytes(rawLine));
Should.Throw<Exception>(() => parser.TryParse(ref buffer, out _));
}
// ─── Logging (log_test.go) ─────────────────────────────────────────────
/// <summary>
/// Serilog file sink creates a log file and writes entries to it.
/// Go: TestSetLogger (log_test.go:29) / TestReOpenLogFile (log_test.go:84)
/// </summary>
[Fact]
public void Log_serilog_file_sink_creates_log_file()
{
// Go: TestSetLogger (log_test.go:29)
var logDir = Path.Combine(Path.GetTempPath(), $"nats-infra-log-{Guid.NewGuid():N}");
Directory.CreateDirectory(logDir);
try
{
var logPath = Path.Combine(logDir, "test.log");
using var logger = new LoggerConfiguration()
.WriteTo.File(logPath)
.CreateLogger();
logger.Information("Hello from infra test");
logger.Dispose();
File.Exists(logPath).ShouldBeTrue();
File.ReadAllText(logPath).ShouldContain("Hello from infra test");
}
finally
{
Directory.Delete(logDir, true);
}
}
/// <summary>
/// Serilog file rotation creates additional log files when size limit is exceeded.
/// Go: TestFileLoggerSizeLimitAndReopen (log_test.go:142) — rotation on size limit
/// </summary>
[Fact]
public void Log_serilog_file_rotation_on_size_limit()
{
// Go: TestFileLoggerSizeLimitAndReopen (log_test.go:142)
var logDir = Path.Combine(Path.GetTempPath(), $"nats-infra-rot-{Guid.NewGuid():N}");
Directory.CreateDirectory(logDir);
try
{
var logPath = Path.Combine(logDir, "rotate.log");
using var logger = new LoggerConfiguration()
.WriteTo.File(logPath, fileSizeLimitBytes: 200, rollOnFileSizeLimit: true,
retainedFileCountLimit: 3)
.CreateLogger();
for (int i = 0; i < 50; i++)
logger.Information("Log message {Number} padding padding padding", i);
logger.Dispose();
Directory.GetFiles(logDir, "rotate*.log").Length.ShouldBeGreaterThan(1);
}
finally
{
Directory.Delete(logDir, true);
}
}
/// <summary>
/// NatsOptions.LogFile, LogSizeLimit, and LogMaxFiles are exposed and settable.
/// Go: TestReOpenLogFile (log_test.go:84) — opts.LogFile is used by server
/// </summary>
[Fact]
public void Log_NatsOptions_log_file_fields_settable()
{
// Go: TestReOpenLogFile (log_test.go:84) — opts.LogFile
var opts = new NatsOptions
{
LogFile = "/tmp/nats.log",
LogSizeLimit = 1024 * 1024,
LogMaxFiles = 5,
Debug = true,
Trace = false,
Logtime = true,
};
opts.LogFile.ShouldBe("/tmp/nats.log");
opts.LogSizeLimit.ShouldBe(1024 * 1024L);
opts.LogMaxFiles.ShouldBe(5);
opts.Debug.ShouldBeTrue();
opts.Trace.ShouldBeFalse();
opts.Logtime.ShouldBeTrue();
}
/// <summary>
/// Server exposes a ReOpenLogFile callback that can be invoked without crashing.
/// Go: TestReOpenLogFile (log_test.go:84) — s.ReOpenLogFile()
/// </summary>
[Fact]
public void Log_server_reopen_log_file_callback_is_invocable()
{
// Go: TestReOpenLogFile (log_test.go:84)
var port = TestPortAllocator.GetFreePort();
using var cts = new CancellationTokenSource();
using var server = new NatsServer(new NatsOptions { Port = port }, NullLoggerFactory.Instance);
bool reopened = false;
server.ReOpenLogFile = () => { reopened = true; };
server.ReOpenLogFile?.Invoke();
reopened.ShouldBeTrue();
}
// ─── Password / token redaction (log_test.go: TestRemovePassFromTrace etc.) ─
/// <summary>
/// CONNECT strings with "pass" key are redacted before tracing.
/// Go: TestRemovePassFromTrace (log_test.go:224)
///
/// The .NET port implements redaction via regex matching "pass":"..." → "pass":"[REDACTED]".
/// </summary>
[Theory]
[InlineData(
"{\"user\":\"derek\",\"pass\":\"s3cr3t\"}",
"{\"user\":\"derek\",\"pass\":\"[REDACTED]\"}")]
[InlineData(
"{\"pass\":\"s3cr3t\",}",
"{\"pass\":\"[REDACTED]\",}")]
[InlineData(
"{\"echo\":true,\"pass\":\"s3cr3t\",\"name\":\"foo\"}",
"{\"echo\":true,\"pass\":\"[REDACTED]\",\"name\":\"foo\"}")]
public void Log_pass_field_is_redacted_in_connect_trace(string input, string expected)
{
// Go: TestRemovePassFromTrace (log_test.go:224)
var result = RedactConnectSecrets(input);
result.ShouldBe(expected);
}
/// <summary>
/// CONNECT strings with "auth_token" key are redacted before tracing.
/// Go: TestRemoveAuthTokenFromTrace (log_test.go:352)
/// </summary>
[Theory]
[InlineData(
"{\"user\":\"derek\",\"auth_token\":\"s3cr3t\"}",
"{\"user\":\"derek\",\"auth_token\":\"[REDACTED]\"}")]
[InlineData(
"{\"auth_token\":\"s3cr3t\",}",
"{\"auth_token\":\"[REDACTED]\",}")]
public void Log_auth_token_field_is_redacted_in_connect_trace(string input, string expected)
{
// Go: TestRemoveAuthTokenFromTrace (log_test.go:352)
var result = RedactConnectSecrets(input);
result.ShouldBe(expected);
}
// Minimal redaction implementation that mirrors the Go removeSecretsFromTrace logic.
// This is sufficient for test parity; the server would call this in its trace path.
private static string RedactConnectSecrets(string input)
{
// Redact "pass":"<value>" — first occurrence only
input = Regex.Replace(input, @"""pass""\s*:\s*""[^""]*""",
@"""pass"":""[REDACTED]""", RegexOptions.None, TimeSpan.FromSeconds(1));
// Redact "auth_token":"<value>" — first occurrence only
input = Regex.Replace(input, @"""auth_token""\s*:\s*""[^""]*""",
@"""auth_token"":""[REDACTED]""", RegexOptions.None, TimeSpan.FromSeconds(1));
return input;
}
// ─── Errors (errors_test.go) ──────────────────────────────────────────────
/// <summary>
/// Error context wrapping: the outer exception message equals the inner message;
/// but the trace includes the context string appended.
/// Go: TestErrCtx (errors_test.go:21)
/// </summary>
[Fact]
public void Error_context_wrapping_preserves_base_message()
{
// Go: TestErrCtx (errors_test.go:21)
var baseMsg = "wrong gateway";
var ctx = "Extra context information";
var baseEx = new InvalidOperationException(baseMsg);
var wrapped = new WrappedNatsException(baseEx, ctx);
// outer message same as inner
wrapped.InnerException!.Message.ShouldBe(baseMsg);
// "unpacked" trace has both
var trace = wrapped.FullTrace();
trace.ShouldStartWith(baseMsg);
trace.ShouldEndWith(ctx);
}
/// <summary>
/// Nested context wrapping: all context levels appear in the trace.
/// Go: TestErrCtxWrapped (errors_test.go:46)
/// </summary>
[Fact]
public void Error_nested_context_all_levels_in_trace()
{
// Go: TestErrCtxWrapped (errors_test.go:46)
var baseMsg = "wrong gateway";
var ctxO = "Original Ctx";
var ctx = "Extra context information";
var baseEx = new InvalidOperationException(baseMsg);
var wrapped1 = new WrappedNatsException(baseEx, ctxO);
var wrapped2 = new WrappedNatsException(wrapped1, ctx);
var trace = wrapped2.FullTrace();
trace.ShouldStartWith(baseMsg);
trace.ShouldEndWith(ctx);
trace.ShouldContain(ctxO);
}
/// <summary>
/// An exception without WrappedNatsException wrapper is passed through unchanged.
/// Go: TestErrCtx (errors_test.go:21) — UnpackIfErrorCtx(ErrWrongGateway) unchanged
/// </summary>
[Fact]
public void Error_plain_exception_unpacked_unchanged()
{
// Go: TestErrCtx (errors_test.go:21)
var plain = new InvalidOperationException("wrong gateway");
plain.Message.ShouldBe("wrong gateway");
}
// ─── Config check (config_check_test.go) ─────────────────────────────────
/// <summary>
/// ConfigProcessorException is thrown for a server_name that contains spaces.
/// Go: TestConfigCheck (config_check_test.go:23) — validation errors are collected and thrown
/// </summary>
[Fact]
public void Config_invalid_server_name_throws_ConfigProcessorException()
{
// Go: TestConfigCheck (config_check_test.go:23) — validation error causes exception
var confPath = Path.GetTempFileName();
try
{
// server_name with spaces is explicitly rejected by ConfigProcessor
File.WriteAllText(confPath, "server_name = \"has spaces\"\n");
Should.Throw<ConfigProcessorException>(() => ConfigProcessor.ProcessConfigFile(confPath));
}
finally
{
File.Delete(confPath);
}
}
/// <summary>
/// A valid minimal config (just a port) loads without errors.
/// Go: TestConfigCheck (config_check_test.go:23) — valid empty authorization block
/// </summary>
[Fact]
public void Config_valid_port_loads_without_error()
{
// Go: TestConfigCheck (config_check_test.go:23)
var confPath = Path.GetTempFileName();
try
{
File.WriteAllText(confPath, "port = 14222\n");
var opts = ConfigProcessor.ProcessConfigFile(confPath);
opts.Port.ShouldBe(14222);
}
finally
{
File.Delete(confPath);
}
}
/// <summary>
/// ConfigProcessorException carries a non-empty Errors list.
/// Go: TestConfigCheckMultipleErrors — multiple errors accumulate
/// </summary>
[Fact]
public void Config_exception_carries_errors_list()
{
// Go: TestConfigCheckMultipleErrors (config_check_test.go) — multiple errors
var ex = new ConfigProcessorException("Configuration errors",
["Error 1: unknown field", "Error 2: bad value"]);
ex.Errors.Count.ShouldBe(2);
ex.Errors.ShouldContain(e => e.Contains("Error 1"));
ex.Errors.ShouldContain(e => e.Contains("Error 2"));
ex.Message.ShouldBe("Configuration errors");
}
// ─── Subject transforms (subject_transform_test.go) ──────────────────────
/// <summary>
/// foo.* → bar.$1 maps single wildcard.
/// Go: TestSubjectTransforms (subject_transform_test.go:138) — shouldMatch "foo.*" "bar.{{Wildcard(1)}}"
/// </summary>
[Fact]
public void SubjectTransform_single_wildcard_replacement()
{
// Go: TestSubjectTransforms (subject_transform_test.go:138)
var tr = SubjectTransform.Create("foo.*", "bar.{{wildcard(1)}}");
tr.ShouldNotBeNull();
tr!.Apply("foo.baz").ShouldBe("bar.baz");
}
/// <summary>
/// foo.*.bar.*.baz → req.$2.$1 reverses order.
/// Go: TestSubjectTransforms (subject_transform_test.go:138)
/// </summary>
[Fact]
public void SubjectTransform_reversal_with_dollar_syntax()
{
// Go: TestSubjectTransforms (subject_transform_test.go:138)
var tr = SubjectTransform.Create("foo.*.bar.*.baz", "req.$2.$1");
tr.ShouldNotBeNull();
tr!.Apply("foo.A.bar.B.baz").ShouldBe("req.B.A");
}
/// <summary>
/// baz.> → my.pre.> passes multi-token remainder.
/// Go: TestSubjectTransforms (subject_transform_test.go:138) — shouldMatch "baz.>" "my.pre.>"
/// </summary>
[Fact]
public void SubjectTransform_full_wildcard_captures_remainder()
{
// Go: TestSubjectTransforms (subject_transform_test.go:138)
var tr = SubjectTransform.Create("baz.>", "my.pre.>");
tr.ShouldNotBeNull();
tr!.Apply("baz.1.2.3").ShouldBe("my.pre.1.2.3");
}
/// <summary>
/// Partition transform produces deterministic results in [0, N) range.
/// Go: TestSubjectTransforms (subject_transform_test.go:138) — partition function
/// </summary>
[Fact]
public void SubjectTransform_partition_result_in_range()
{
// Go: TestSubjectTransforms (subject_transform_test.go:138) — partition
var tr = SubjectTransform.Create("*", "bar.{{partition(10)}}");
tr.ShouldNotBeNull();
var result = tr!.Apply("foo");
result.ShouldNotBeNull();
result!.ShouldStartWith("bar.");
var partStr = result.Substring("bar.".Length);
int.TryParse(partStr, out var part).ShouldBeTrue();
part.ShouldBeInRange(0, 9);
}
/// <summary>
/// Specific partition values for known inputs match Go reference.
/// Go: TestSubjectTransforms (subject_transform_test.go:236) — shouldMatch "*" "bar.{{partition(10)}}" "foo" → "bar.3"
/// </summary>
[Theory]
[InlineData("foo", 10, 3)]
[InlineData("baz", 10, 0)]
[InlineData("qux", 10, 9)]
public void SubjectTransform_partition_specific_values(string subject, int buckets, int expectedPartition)
{
// Go: TestSubjectTransforms (subject_transform_test.go:236-241)
var tr = SubjectTransform.Create("*", $"bar.{{{{partition({buckets})}}}}");
tr.ShouldNotBeNull();
tr!.Apply(subject).ShouldBe($"bar.{expectedPartition}");
}
/// <summary>
/// SplitFromLeft creates dots at the specified position.
/// Go: TestSubjectTransforms (subject_transform_test.go:138) — shouldMatch "*" "{{splitfromleft(1,3)}}" "12345" "123.45"
/// </summary>
[Fact]
public void SubjectTransform_split_from_left()
{
// Go: TestSubjectTransforms (subject_transform_test.go:138)
var tr = SubjectTransform.Create("*", "{{splitfromleft(1,3)}}");
tr.ShouldNotBeNull();
tr!.Apply("12345").ShouldBe("123.45");
}
/// <summary>
/// Invalid source (foo..) throws or returns null.
/// Go: TestSubjectTransforms (subject_transform_test.go:138) — shouldErr "foo.." "bar"
/// </summary>
[Fact]
public void SubjectTransform_invalid_source_returns_null()
{
// Go: TestSubjectTransforms (subject_transform_test.go:138) — shouldErr "foo.." "bar"
var tr = SubjectTransform.Create("foo..", "bar");
tr.ShouldBeNull();
}
/// <summary>
/// Out-of-range wildcard index returns null.
/// Go: TestSubjectTransforms (subject_transform_test.go:138) — shouldErr "foo.*" "foo.{{wildcard(2)}}"
/// </summary>
[Fact]
public void SubjectTransform_out_of_range_wildcard_returns_null()
{
// Go: TestSubjectTransforms (subject_transform_test.go:138)
var tr = SubjectTransform.Create("foo.*", "foo.{{wildcard(2)}}");
tr.ShouldBeNull();
}
/// <summary>
/// TransformTokenizedSubject does not panic when a wildcard token is missing.
/// Go: TestSubjectTransformDoesntPanicTransformingMissingToken (subject_transform_test.go:252)
/// </summary>
[Fact]
public void SubjectTransform_no_panic_when_token_missing()
{
// Go: TestSubjectTransformDoesntPanicTransformingMissingToken (subject_transform_test.go:252)
var tr = SubjectTransform.Create("foo.*", "one.two.{{wildcard(1)}}");
tr.ShouldNotBeNull();
// Passing a tokenised subject with fewer tokens than expected should not throw;
// .NET's Apply on a non-matching subject returns null safely.
var result = tr!.Apply("foo"); // missing the wildcard token
result.ShouldBeNull();
}
// ─── NKey auth (nkey_test.go) ──────────────────────────────────────────────
/// <summary>
/// AuthService.NonceRequired is false when no NKeys are configured.
/// Go: TestServerInfoNonce (nkey_test.go:80) — no nkeys → empty nonce
/// </summary>
[Fact]
public void NKey_auth_service_nonce_not_required_without_nkeys()
{
// Go: TestServerInfoNonce (nkey_test.go:80) — no nkeys → no nonce
var auth = AuthService.Build(new NatsOptions());
auth.NonceRequired.ShouldBeFalse();
}
/// <summary>
/// AuthService.NonceRequired is true when NKeys are configured.
/// Go: TestServerInfoNonce (nkey_test.go:80) — with nkeys → non-empty nonce
/// </summary>
[Fact]
public void NKey_auth_service_nonce_required_with_nkeys()
{
// Go: TestServerInfoNonce (nkey_test.go:80) — nkeys → nonce required
var kp = KeyPair.CreatePair(PrefixByte.User);
var pubKey = kp.GetPublicKey();
var auth = AuthService.Build(new NatsOptions
{
NKeys = [new NKeyUser { Nkey = pubKey }],
});
auth.NonceRequired.ShouldBeTrue();
}
/// <summary>
/// GenerateNonce produces non-empty, different values on successive calls.
/// Go: TestServerInfoNonce (nkey_test.go:80) — each client gets a new nonce
/// </summary>
[Fact]
public void NKey_generate_nonce_produces_unique_values()
{
// Go: TestServerInfoNonce (nkey_test.go:80) — unique nonces per connection
var kp = KeyPair.CreatePair(PrefixByte.User);
var pubKey = kp.GetPublicKey();
var auth = AuthService.Build(new NatsOptions
{
NKeys = [new NKeyUser { Nkey = pubKey }],
});
var nonce1 = auth.GenerateNonce();
var nonce2 = auth.GenerateNonce();
nonce1.ShouldNotBeEmpty();
nonce2.ShouldNotBeEmpty();
nonce1.ShouldNotBe(nonce2);
}
/// <summary>
/// EncodeNonce produces a non-empty base64url-ish string from raw nonce bytes.
/// Go: nkey_test.go — BenchmarkNonceGeneration
/// </summary>
[Fact]
public void NKey_encode_nonce_produces_non_empty_string()
{
// Go: nkey_test.go — BenchmarkNonceGeneration
var kp = KeyPair.CreatePair(PrefixByte.User);
var auth = AuthService.Build(new NatsOptions
{
NKeys = [new NKeyUser { Nkey = kp.GetPublicKey() }],
});
var raw = auth.GenerateNonce();
var encoded = auth.EncodeNonce(raw);
encoded.ShouldNotBeNullOrEmpty();
encoded.Length.ShouldBeGreaterThan(0);
}
/// <summary>
/// INFO JSON sent to a client includes a nonce when NKeys are configured.
/// Go: TestServerInfoNonceAlwaysEnabled (nkey_test.go:58) — nonce in INFO
/// </summary>
[Fact]
public async Task NKey_info_json_contains_nonce_when_nkeys_configured()
{
// Go: TestServerInfoNonceAlwaysEnabled (nkey_test.go:58)
var port = TestPortAllocator.GetFreePort();
using var cts = new CancellationTokenSource();
var kp = KeyPair.CreatePair(PrefixByte.User);
var pubKey = kp.GetPublicKey();
using var server = new NatsServer(new NatsOptions
{
Port = port,
NKeys = [new NKeyUser { Nkey = pubKey }],
}, NullLoggerFactory.Instance);
_ = server.StartAsync(cts.Token);
await server.WaitForReadyAsync();
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await sock.ConnectAsync(IPAddress.Loopback, port);
var info = await SocketTestHelper.ReadUntilAsync(sock, "\r\n");
sock.Dispose();
await cts.CancelAsync();
info.ShouldContain("nonce");
}
// ─── Ping (ping_test.go) ─────────────────────────────────────────────────
/// <summary>
/// Server sends PING frames at PingInterval and client PONG keeps connection alive.
/// Go: TestPing (ping_test.go:34) — server pings at configured interval
/// </summary>
[Fact]
public async Task Ping_server_sends_ping_at_interval()
{
// Go: TestPing (ping_test.go:34)
var port = TestPortAllocator.GetFreePort();
using var cts = new CancellationTokenSource();
using var server = new NatsServer(new NatsOptions
{
Port = port,
PingInterval = TimeSpan.FromMilliseconds(100),
MaxPingsOut = 3,
// Go: TestPing sets o.DisableShortFirstPing = true to avoid the
// 2-second grace period and activity-based suppression race.
DisableShortFirstPing = true,
}, NullLoggerFactory.Instance);
_ = server.StartAsync(cts.Token);
await server.WaitForReadyAsync();
var conn = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await conn.ConnectAsync(IPAddress.Loopback, port);
await SocketTestHelper.ReadUntilAsync(conn, "\r\n"); // consume INFO
// Establish the connection
await conn.SendAsync("CONNECT {\"verbose\":false}\r\nPING\r\n"u8.ToArray());
await SocketTestHelper.ReadUntilAsync(conn, "PONG"); // initial PONG
// Wait for the server to send a PING
var received = await SocketTestHelper.ReadUntilAsync(conn, "PING", 2000);
received.ShouldContain("PING");
// Reply with PONG to keep alive
await conn.SendAsync("PONG\r\n"u8.ToArray());
conn.Dispose();
await cts.CancelAsync();
}
/// <summary>
/// NatsOptions.PingInterval and MaxPingsOut are configurable.
/// Go: TestPing (ping_test.go:34) — PingInterval, MaxPingsOut set on options
/// </summary>
[Fact]
public void Ping_options_are_configurable()
{
// Go: TestPing (ping_test.go:34)
var opts = new NatsOptions
{
PingInterval = TimeSpan.FromMilliseconds(50),
MaxPingsOut = 5,
};
opts.PingInterval.ShouldBe(TimeSpan.FromMilliseconds(50));
opts.MaxPingsOut.ShouldBe(5);
}
// ─── Util (util_test.go) ─────────────────────────────────────────────────
/// <summary>
/// Comma-formatted number with thousands separators.
/// Go: TestComma (util_test.go:118)
/// </summary>
[Theory]
[InlineData(0, "0")]
[InlineData(10, "10")]
[InlineData(100, "100")]
[InlineData(1000, "1,000")]
[InlineData(10000, "10,000")]
[InlineData(100000, "100,000")]
[InlineData(10000000, "10,000,000")]
[InlineData(123456789, "123,456,789")]
[InlineData(-1000, "-1,000")]
[InlineData(-100000, "-100,000")]
public void Util_comma_formats_numbers_with_thousands_separators(long n, string expected)
{
// Go: TestComma (util_test.go:118) — comma() helper
CommaFormat(n).ShouldBe(expected);
}
/// <summary>
/// URL redaction replaces password with "xxxxx".
/// Go: TestURLRedaction (util_test.go:164)
/// </summary>
[Theory]
[InlineData("nats://foo:bar@example.org", "nats://foo:xxxxx@example.org")]
[InlineData("nats://foo@example.org", "nats://foo@example.org")]
[InlineData("nats://example.org", "nats://example.org")]
public void Util_url_password_is_redacted(string full, string expected)
{
// Go: TestURLRedaction (util_test.go:164)
RedactUrl(full).ShouldBe(expected);
}
/// <summary>
/// Version comparison works correctly: "at least major.minor.patch".
/// Go: TestVersionAtLeast (util_test.go:195)
/// </summary>
[Theory]
[InlineData("2.0.0-beta", 1, 9, 9, true)]
[InlineData("2.0.0", 1, 99, 9, true)]
[InlineData("2.2.2", 2, 2, 2, true)]
[InlineData("2.2.2", 2, 2, 3, false)]
[InlineData("2.2.2", 2, 3, 2, false)]
[InlineData("2.2.2", 3, 2, 2, false)]
[InlineData("bad.version", 1, 2, 3, false)]
public void Util_version_at_least_comparison(string version, int major, int minor, int update, bool expected)
{
// Go: TestVersionAtLeast (util_test.go:195)
VersionAtLeast(version, major, minor, update).ShouldBe(expected);
}
// Minimal port of Go's comma() utility
private static string CommaFormat(long n)
{
if (n == 0) return "0";
bool negative = n < 0;
var abs = negative ? (ulong)(-n) : (ulong)n;
var digits = abs.ToString();
var sb = new StringBuilder();
int start = digits.Length % 3;
if (start == 0) start = 3;
sb.Append(digits[..start]);
for (int i = start; i < digits.Length; i += 3)
{
sb.Append(',');
sb.Append(digits[i..(i + 3)]);
}
return negative ? "-" + sb : sb.ToString();
}
// Minimal port of Go's redactURLString() — replaces password in URL with "xxxxx"
private static string RedactUrl(string urlStr)
{
if (!Uri.TryCreate(urlStr, UriKind.Absolute, out var uri))
return urlStr;
if (string.IsNullOrEmpty(uri.UserInfo) || !uri.UserInfo.Contains(':'))
return urlStr;
// Use regex substitution to avoid UriBuilder appending a trailing slash
return Regex.Replace(urlStr,
@"(://" + Regex.Escape(uri.UserInfo.Split(':')[0]) + @":)[^@]+(@)",
m => m.Groups[1].Value + "xxxxx" + m.Groups[2].Value);
}
// Minimal port of Go's versionAtLeast()
private static bool VersionAtLeast(string version, int major, int minor, int update)
{
// Strip pre-release suffix
var hyphen = version.IndexOf('-');
if (hyphen >= 0) version = version[..hyphen];
var parts = version.Split('.');
if (parts.Length < 3) return false;
if (!int.TryParse(parts[0], out int vMaj)
|| !int.TryParse(parts[1], out int vMin)
|| !int.TryParse(parts[2], out int vUpd))
return false;
if (vMaj != major) return vMaj > major;
if (vMin != minor) return vMin > minor;
return vUpd >= update;
}
// ─── Trust keys (trust_test.go) ────────────────────────────────────────────
/// <summary>
/// NatsOptions.TrustedKeys accepts a list of operator public keys.
/// Go: TestTrustedKeysOptions (trust_test.go:60)
/// </summary>
[Fact]
public void Trust_trusted_keys_can_be_set_in_options()
{
// Go: TestTrustedKeysOptions (trust_test.go:60)
// Use real operator NKey format keys (56-char base32)
var kp1 = KeyPair.CreatePair(PrefixByte.Operator);
var t1 = kp1.GetPublicKey();
var kp2 = KeyPair.CreatePair(PrefixByte.Operator);
var t2 = kp2.GetPublicKey();
var opts = new NatsOptions { TrustedKeys = [t1, t2] };
opts.TrustedKeys.ShouldNotBeNull();
opts.TrustedKeys!.Length.ShouldBe(2);
opts.TrustedKeys[0].ShouldBe(t1);
opts.TrustedKeys[1].ShouldBe(t2);
}
/// <summary>
/// TrustedKeys defaults to null (operator mode disabled by default).
/// Go: TestTrustedKeysOptions (trust_test.go:60) — opts.TrustedKeys is nil when not set
/// </summary>
[Fact]
public void Trust_trusted_keys_default_is_null()
{
// Go: TestTrustedKeysOptions (trust_test.go:60)
new NatsOptions().TrustedKeys.ShouldBeNull();
}
/// <summary>
/// AuthService with TrustedKeys but no AccountResolver does not add JWT authenticator.
/// Go: trust_test.go — configuration requires both TrustedKeys and AccountResolver
/// </summary>
[Fact]
public void Trust_trusted_keys_without_account_resolver_does_not_enable_nonce()
{
// Go: trust_test.go — TrustedKeys alone is not enough to enable JWT auth
var kp = KeyPair.CreatePair(PrefixByte.Operator);
var auth = AuthService.Build(new NatsOptions
{
TrustedKeys = [kp.GetPublicKey()],
// No AccountResolver — JWT auth not activated
});
// NonceRequired should NOT be true because there's no AccountResolver
auth.NonceRequired.ShouldBeFalse();
}
}
/// <summary>
/// Minimal error-context wrapper that mirrors Go's NewErrorCtx / UnpackIfErrorCtx.
/// Go: errors_test.go — NewErrorCtx, UnpackIfErrorCtx, ErrorIs
/// </summary>
file sealed class WrappedNatsException(Exception inner, string context)
: Exception(inner.Message, inner)
{
private readonly string _context = context;
/// <summary>Returns the full trace: baseMsg → ctx1 → ctx2 → … → outerCtx.</summary>
public string FullTrace()
{
// Walk the InnerException chain collecting contexts
var parts = new List<string>();
Exception? current = this;
while (current != null)
{
if (current is WrappedNatsException w)
parts.Add(w._context);
else
parts.Insert(0, current.Message);
current = current.InnerException;
}
// Reverse so base message is first, contexts follow
parts.Reverse();
// parts[0] = context of outermost, last = innermost context
// We want: baseMsg, then contexts in order from inner-most to outer-most
// Walk differently: collect from bottom (plain exception) up
var trace = new List<string>();
var chain = new List<Exception>();
Exception? e = this;
while (e != null) { chain.Add(e); e = e.InnerException; }
chain.Reverse(); // bottom first
foreach (var ex in chain)
{
if (ex is WrappedNatsException wn)
trace.Add(wn._context);
else
trace.Insert(0, ex.Message);
}
return string.Join(" ", trace);
}
}