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.
1093 lines
41 KiB
C#
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);
|
|
}
|
|
}
|