refactor: extract NATS.Server.Monitoring.Tests project

Move 39 monitoring, events, and system endpoint test files from
NATS.Server.Tests into a dedicated NATS.Server.Monitoring.Tests project.
Update namespaces, replace private GetFreePort/ReadUntilAsync with
TestUtilities shared helpers, add InternalsVisibleTo, and register
in the solution file. All 439 tests pass.
This commit is contained in:
Joseph Doherty
2026-03-12 15:44:12 -04:00
parent edf9ed770e
commit 0c086522a4
42 changed files with 131 additions and 257 deletions

View File

@@ -0,0 +1,113 @@
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server.Events;
using NATS.Server.TestUtilities;
namespace NATS.Server.Monitoring.Tests;
public class EventSystemTests
{
[Fact]
public void ConnectEventMsg_serializes_with_correct_type()
{
var evt = new ConnectEventMsg
{
Type = ConnectEventMsg.EventType,
Id = "test123",
Time = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
Server = new EventServerInfo { Name = "test-server", Id = "SRV1" },
Client = new EventClientInfo { Id = 1, Account = "$G" },
};
var json = JsonSerializer.Serialize(evt, EventJsonContext.Default.ConnectEventMsg);
json.ShouldContain("\"type\":\"io.nats.server.advisory.v1.client_connect\"");
json.ShouldContain("\"server\":");
json.ShouldContain("\"client\":");
}
[Fact]
public void DisconnectEventMsg_serializes_with_reason()
{
var evt = new DisconnectEventMsg
{
Type = DisconnectEventMsg.EventType,
Id = "test456",
Time = DateTime.UtcNow,
Server = new EventServerInfo { Name = "test-server", Id = "SRV1" },
Client = new EventClientInfo { Id = 2, Account = "myacc" },
Reason = "Client Closed",
Sent = new DataStats { Msgs = 10, Bytes = 1024 },
Received = new DataStats { Msgs = 5, Bytes = 512 },
};
var json = JsonSerializer.Serialize(evt, EventJsonContext.Default.DisconnectEventMsg);
json.ShouldContain("\"reason\":\"Client Closed\"");
}
[Fact]
public void ServerStatsMsg_serializes()
{
var evt = new ServerStatsMsg
{
Server = new EventServerInfo { Name = "srv1", Id = "ABC" },
Stats = new ServerStatsData
{
Connections = 10,
TotalConnections = 100,
InMsgs = 5000,
OutMsgs = 4500,
InBytes = 1_000_000,
OutBytes = 900_000,
Mem = 50 * 1024 * 1024,
Subscriptions = 42,
},
};
var json = JsonSerializer.Serialize(evt, EventJsonContext.Default.ServerStatsMsg);
json.ShouldContain("\"connections\":10");
json.ShouldContain("\"in_msgs\":5000");
}
[Fact]
public async Task InternalEventSystem_start_and_stop_lifecycle()
{
using var server = CreateTestServer();
_ = server.StartAsync(CancellationToken.None);
await server.WaitForReadyAsync();
var eventSystem = server.EventSystem;
eventSystem.ShouldNotBeNull();
eventSystem.SystemClient.ShouldNotBeNull();
eventSystem.SystemClient.Kind.ShouldBe(ClientKind.System);
await server.ShutdownAsync();
}
[Fact]
public async Task SendInternalMsg_delivers_to_system_subscriber()
{
using var server = CreateTestServer();
_ = server.StartAsync(CancellationToken.None);
await server.WaitForReadyAsync();
var received = new TaskCompletionSource<string>();
server.EventSystem!.SysSubscribe("test.subject", (sub, client, acc, subject, reply, hdr, msg) =>
{
received.TrySetResult(subject);
});
server.SendInternalMsg("test.subject", null, new { Value = "hello" });
var result = await received.Task.WaitAsync(TimeSpan.FromSeconds(5));
result.ShouldBe("test.subject");
await server.ShutdownAsync();
}
private static NatsServer CreateTestServer()
{
var port = TestPortAllocator.GetFreePort();
return new NatsServer(new NatsOptions { Port = port }, NullLoggerFactory.Instance);
}
}

View File

@@ -0,0 +1,286 @@
// Port of Go server/events_test.go — auth error advisory publication tests.
// Go reference: golang/nats-server/server/events.go:2631 sendAuthErrorEvent.
//
// Tests cover: SendAuthErrorEvent counter, enqueue behaviour, record field
// preservation, SendConnectEvent, SendDisconnectEvent, and the supporting
// detail record types AuthErrorDetail, ConnectEventDetail, DisconnectEventDetail.
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server;
using NATS.Server.Events;
using NATS.Server.TestUtilities;
namespace NATS.Server.Monitoring.Tests.Events;
/// <summary>
/// Tests for <see cref="InternalEventSystem.SendAuthErrorEvent"/>,
/// <see cref="InternalEventSystem.SendConnectEvent"/>,
/// <see cref="InternalEventSystem.SendDisconnectEvent"/>, and the three
/// companion detail record types.
/// Go reference: events_test.go TestSystemAccountDisconnectBadLogin,
/// TestSystemAccountNewConnection.
/// </summary>
public class AuthErrorEventTests : IAsyncLifetime
{
private NatsServer _server = null!;
private int _port;
public async Task InitializeAsync()
{
_port = TestPortAllocator.GetFreePort();
_server = new NatsServer(new NatsOptions { Port = _port }, NullLoggerFactory.Instance);
_ = _server.StartAsync(CancellationToken.None);
await _server.WaitForReadyAsync();
}
public async Task DisposeAsync()
{
await _server.ShutdownAsync();
_server.Dispose();
}
// ========================================================================
// AuthErrorEventCount
// Go reference: events.go:2631 sendAuthErrorEvent — counter per advisory
// ========================================================================
/// <summary>
/// AuthErrorEventCount starts at zero before any advisories are sent.
/// Go reference: events_test.go TestSystemAccountDisconnectBadLogin.
/// </summary>
[Fact]
public void AuthErrorEventCount_StartsAtZero()
{
// Go reference: events_test.go TestSystemAccountDisconnectBadLogin — no events at startup.
var es = _server.EventSystem!;
es.AuthErrorEventCount.ShouldBe(0L);
}
/// <summary>
/// Calling SendAuthErrorEvent once increments the counter to 1.
/// Go reference: events.go:2631 sendAuthErrorEvent — each call is one advisory.
/// </summary>
[Fact]
public void SendAuthErrorEvent_IncrementsCounter()
{
// Go reference: events_test.go TestSystemAccountDisconnectBadLogin.
var es = _server.EventSystem!;
var detail = new AuthErrorDetail(
ClientId: 42,
RemoteAddress: "127.0.0.1:5000",
AccountName: "$G",
UserName: "alice",
Reason: "Authorization Violation",
OccurredAt: DateTime.UtcNow);
es.SendAuthErrorEvent(_server.ServerId, detail);
es.AuthErrorEventCount.ShouldBe(1L);
}
/// <summary>
/// Each call to SendAuthErrorEvent enqueues a message (counter grows by one per call).
/// Go reference: events.go:2687 sendInternalMsg — advisory is always enqueued.
/// </summary>
[Fact]
public void SendAuthErrorEvent_EnqueuesMessage()
{
// Go reference: events.go sendAuthErrorEvent publishes via sendInternalMsg.
var es = _server.EventSystem!;
var detail = new AuthErrorDetail(
ClientId: 7,
RemoteAddress: "10.0.0.1:4222",
AccountName: null,
UserName: null,
Reason: "Authentication Timeout",
OccurredAt: DateTime.UtcNow);
var before = es.AuthErrorEventCount;
es.SendAuthErrorEvent(_server.ServerId, detail);
var after = es.AuthErrorEventCount;
// The counter increment is the observable side-effect of the enqueue path.
(after - before).ShouldBe(1L);
}
/// <summary>
/// Sending multiple auth error events increments the counter for each.
/// Go reference: events.go:2631 sendAuthErrorEvent — cumulative count.
/// </summary>
[Fact]
public void AuthErrorEventCount_MultipleSends_Incremented()
{
// Go reference: events_test.go TestSystemAccountDisconnectBadLogin.
var es = _server.EventSystem!;
var detail = new AuthErrorDetail(
ClientId: 1,
RemoteAddress: "192.168.1.1:9999",
AccountName: "myacc",
UserName: "bob",
Reason: "Bad credentials",
OccurredAt: DateTime.UtcNow);
var before = es.AuthErrorEventCount;
const int count = 5;
for (var i = 0; i < count; i++)
es.SendAuthErrorEvent(_server.ServerId, detail);
(es.AuthErrorEventCount - before).ShouldBe(count);
}
// ========================================================================
// SendConnectEvent
// Go reference: events.go postConnectEvent / sendConnect
// ========================================================================
/// <summary>
/// SendConnectEvent enqueues a message without throwing.
/// Go reference: events.go postConnectEvent — advisory fired on client connect.
/// </summary>
[Fact]
public void SendConnectEvent_EnqueuesMessage()
{
// Go reference: events_test.go TestSystemAccountNewConnection.
var es = _server.EventSystem!;
var detail = new ConnectEventDetail(
ClientId: 10,
RemoteAddress: "127.0.0.1:6000",
AccountName: "$G",
UserName: "user1",
ConnectedAt: DateTime.UtcNow);
var ex = Record.Exception(() => es.SendConnectEvent(_server.ServerId, detail));
ex.ShouldBeNull();
}
// ========================================================================
// SendDisconnectEvent
// Go reference: events.go postDisconnectEvent / sendDisconnect
// ========================================================================
/// <summary>
/// SendDisconnectEvent enqueues a message without throwing.
/// Go reference: events.go postDisconnectEvent — advisory fired on client disconnect.
/// </summary>
[Fact]
public void SendDisconnectEvent_EnqueuesMessage()
{
// Go reference: events_test.go TestSystemAccountNewConnection (disconnect part).
var es = _server.EventSystem!;
var detail = new DisconnectEventDetail(
ClientId: 20,
RemoteAddress: "127.0.0.1:7000",
AccountName: "$G",
Reason: "Client Closed",
DisconnectedAt: DateTime.UtcNow);
var ex = Record.Exception(() => es.SendDisconnectEvent(_server.ServerId, detail));
ex.ShouldBeNull();
}
// ========================================================================
// AuthErrorDetail record
// ========================================================================
/// <summary>
/// AuthErrorDetail preserves all fields passed to its constructor.
/// Go reference: events.go:2631 — all client fields captured in the advisory.
/// </summary>
[Fact]
public void AuthErrorDetail_PreservesAllFields()
{
// Go reference: events_test.go TestSystemAccountDisconnectBadLogin.
var now = DateTime.UtcNow;
var detail = new AuthErrorDetail(
ClientId: 99,
RemoteAddress: "10.0.0.2:1234",
AccountName: "test-account",
UserName: "testuser",
Reason: "Authorization Violation",
OccurredAt: now);
detail.ClientId.ShouldBe(99UL);
detail.RemoteAddress.ShouldBe("10.0.0.2:1234");
detail.AccountName.ShouldBe("test-account");
detail.UserName.ShouldBe("testuser");
detail.Reason.ShouldBe("Authorization Violation");
detail.OccurredAt.ShouldBe(now);
}
/// <summary>
/// AuthErrorDetail accepts a non-empty Reason (the key advisory field).
/// Go reference: events.go:2631 sendAuthErrorEvent — reason is always set.
/// </summary>
[Fact]
public void AuthErrorDetail_ReasonRequired()
{
// Go reference: events_test.go TestSystemAccountDisconnectBadLogin — reason distinguishes error types.
var detail = new AuthErrorDetail(
ClientId: 1,
RemoteAddress: "127.0.0.1:0",
AccountName: null,
UserName: null,
Reason: "Authentication Timeout",
OccurredAt: DateTime.UtcNow);
detail.Reason.ShouldNotBeNullOrEmpty();
detail.Reason.ShouldBe("Authentication Timeout");
}
// ========================================================================
// ConnectEventDetail record
// ========================================================================
/// <summary>
/// ConnectEventDetail preserves all constructor fields.
/// Go reference: events.go postConnectEvent — all fields captured on connect.
/// </summary>
[Fact]
public void ConnectEventDetail_PreservesFields()
{
// Go reference: events_test.go TestSystemAccountNewConnection.
var connectedAt = new DateTime(2026, 2, 25, 10, 0, 0, DateTimeKind.Utc);
var detail = new ConnectEventDetail(
ClientId: 55,
RemoteAddress: "192.168.0.5:8080",
AccountName: "prod-account",
UserName: "svc-user",
ConnectedAt: connectedAt);
detail.ClientId.ShouldBe(55UL);
detail.RemoteAddress.ShouldBe("192.168.0.5:8080");
detail.AccountName.ShouldBe("prod-account");
detail.UserName.ShouldBe("svc-user");
detail.ConnectedAt.ShouldBe(connectedAt);
}
// ========================================================================
// DisconnectEventDetail record
// ========================================================================
/// <summary>
/// DisconnectEventDetail preserves all constructor fields.
/// Go reference: events.go postDisconnectEvent — all fields captured on disconnect.
/// </summary>
[Fact]
public void DisconnectEventDetail_PreservesFields()
{
// Go reference: events_test.go TestSystemAccountNewConnection (disconnect part).
var disconnectedAt = new DateTime(2026, 2, 25, 11, 0, 0, DateTimeKind.Utc);
var detail = new DisconnectEventDetail(
ClientId: 77,
RemoteAddress: "172.16.0.3:3000",
AccountName: "staging-account",
Reason: "Slow Consumer",
DisconnectedAt: disconnectedAt);
detail.ClientId.ShouldBe(77UL);
detail.RemoteAddress.ShouldBe("172.16.0.3:3000");
detail.AccountName.ShouldBe("staging-account");
detail.Reason.ShouldBe("Slow Consumer");
detail.DisconnectedAt.ShouldBe(disconnectedAt);
}
}

View File

@@ -0,0 +1,152 @@
using System.Text.Json;
using NATS.Server.Events;
namespace NATS.Server.Monitoring.Tests.Events;
public class EventApiAndSubjectsParityBatch2Tests
{
[Fact]
public void EventSubjects_DefineMissingServerRequestSubjects()
{
EventSubjects.RemoteLatency.ShouldBe("$SYS.SERVER.{0}.ACC.{1}.LATENCY.M2");
EventSubjects.UserDirectInfo.ShouldBe("$SYS.REQ.USER.INFO");
EventSubjects.UserDirectReq.ShouldBe("$SYS.REQ.USER.{0}.INFO");
EventSubjects.AccountNumSubsReq.ShouldBe("$SYS.REQ.ACCOUNT.NSUBS");
EventSubjects.AccountSubs.ShouldBe("$SYS._INBOX_.{0}.NSUBS");
EventSubjects.ClientKickReq.ShouldBe("$SYS.REQ.SERVER.{0}.KICK");
EventSubjects.ClientLdmReq.ShouldBe("$SYS.REQ.SERVER.{0}.LDM");
EventSubjects.ServerStatsPingReq.ShouldBe("$SYS.REQ.SERVER.PING.STATSZ");
EventSubjects.ServerReloadReq.ShouldBe("$SYS.REQ.SERVER.{0}.RELOAD");
}
[Fact]
public void OcspSubjects_MatchGoPatterns()
{
EventSubjects.OcspPeerReject.ShouldBe("$SYS.SERVER.{0}.OCSP.PEER.CONN.REJECT");
EventSubjects.OcspPeerChainlinkInvalid.ShouldBe("$SYS.SERVER.{0}.OCSP.PEER.LINK.INVALID");
}
[Fact]
public void OcspPeerRejectEvent_IncludesPeerCertInfo()
{
var evt = new OcspPeerRejectEventMsg
{
Id = "id",
Kind = "client",
Reason = "revoked",
Peer = new EventCertInfo
{
Subject = "CN=client",
Issuer = "CN=issuer",
Fingerprint = "fingerprint",
Raw = "raw",
},
};
var json = JsonSerializer.Serialize(evt);
json.ShouldContain("\"peer\":");
json.ShouldContain("\"subject\":\"CN=client\"");
}
[Fact]
public void OcspPeerChainlinkInvalidEvent_SerializesExpectedShape()
{
var evt = new OcspPeerChainlinkInvalidEventMsg
{
Id = "id",
Link = new EventCertInfo { Subject = "CN=link" },
Peer = new EventCertInfo { Subject = "CN=peer" },
};
var json = JsonSerializer.Serialize(evt);
json.ShouldContain("\"type\":\"io.nats.server.advisory.v1.ocsp_peer_link_invalid\"");
json.ShouldContain("\"link\":");
json.ShouldContain("\"peer\":");
}
[Fact]
public void EventFilterOptions_HasCoreGoFields()
{
var opts = new EventFilterOptions
{
Name = "srv-a",
Cluster = "cluster-a",
Host = "127.0.0.1",
Tags = ["a", "b"],
Domain = "domain-a",
};
opts.Name.ShouldBe("srv-a");
opts.Cluster.ShouldBe("cluster-a");
opts.Host.ShouldBe("127.0.0.1");
opts.Tags.ShouldBe(["a", "b"]);
opts.Domain.ShouldBe("domain-a");
}
[Fact]
public void OptionRequestTypes_IncludeBaseFilterFields()
{
new StatszEventOptions { Name = "n" }.Name.ShouldBe("n");
new ConnzEventOptions { Cluster = "c" }.Cluster.ShouldBe("c");
new RoutezEventOptions { Host = "h" }.Host.ShouldBe("h");
new HealthzEventOptions { Domain = "d" }.Domain.ShouldBe("d");
new JszEventOptions { Tags = ["t"] }.Tags.ShouldBe(["t"]);
}
[Fact]
public void ServerApiResponses_ExposeDataAndError()
{
var response = new ServerAPIResponse
{
Server = new EventServerInfo { Id = "S1" },
Data = new { ok = true },
Error = new ServerAPIError { Code = 500, Description = "err" },
};
response.Server.Id.ShouldBe("S1");
response.Error?.Code.ShouldBe(500);
response.Error?.Description.ShouldBe("err");
}
[Fact]
public void TypedServerApiWrappers_CarryResponsePayload()
{
new ServerAPIConnzResponse { Data = new object() }.Data.ShouldNotBeNull();
new ServerAPIRoutezResponse { Data = new object() }.Data.ShouldNotBeNull();
new ServerAPIGatewayzResponse { Data = new object() }.Data.ShouldNotBeNull();
new ServerAPIJszResponse { Data = new object() }.Data.ShouldNotBeNull();
new ServerAPIHealthzResponse { Data = new object() }.Data.ShouldNotBeNull();
new ServerAPIVarzResponse { Data = new object() }.Data.ShouldNotBeNull();
new ServerAPISubszResponse { Data = new object() }.Data.ShouldNotBeNull();
new ServerAPILeafzResponse { Data = new object() }.Data.ShouldNotBeNull();
new ServerAPIAccountzResponse { Data = new object() }.Data.ShouldNotBeNull();
new ServerAPIExpvarzResponse { Data = new object() }.Data.ShouldNotBeNull();
new ServerAPIpqueueszResponse { Data = new object() }.Data.ShouldNotBeNull();
new ServerAPIRaftzResponse { Data = new object() }.Data.ShouldNotBeNull();
}
[Fact]
public void RequestPayloadTypes_KickAndLdm()
{
var kick = new KickClientReq { ClientId = 22 };
var ldm = new LDMClientReq { ClientId = 33 };
kick.ClientId.ShouldBe(22UL);
ldm.ClientId.ShouldBe(33UL);
}
[Fact]
public void UserInfo_IncludesExpectedIdentityFields()
{
var info = new UserInfo
{
User = "alice",
Account = "A",
Permissions = "pubsub",
};
info.User.ShouldBe("alice");
info.Account.ShouldBe("A");
info.Permissions.ShouldBe("pubsub");
}
}

View File

@@ -0,0 +1,198 @@
// Go reference: server/events.go:2082-2090 — compressionType / snappyCompression,
// and events.go:578-598 — internalSendLoop optional compression branch.
using System.Text;
using NATS.Server.Events;
namespace NATS.Server.Monitoring.Tests.Events;
/// <summary>
/// Tests for <see cref="EventCompressor"/> — S2/Snappy compression for system event payloads.
/// Go reference: server/events.go — compressed system events via snappyCompression.
/// </summary>
public class EventCompressionTests : IDisposable
{
public EventCompressionTests()
{
// Ensure a clean statistics baseline for every test.
EventCompressor.ResetStats();
}
public void Dispose()
{
EventCompressor.ResetStats();
}
// ── 1 ──────────────────────────────────────────────────────────────────────
[Fact]
public void Compress_ValidPayload_ReturnsCompressed()
{
// Arrange
var json = """{"server":{"name":"s1","id":"ABCDEF"},"data":{"conns":42,"bytes":1024}}""";
var payload = Encoding.UTF8.GetBytes(json);
// Act
var compressed = EventCompressor.Compress(payload);
// Assert
compressed.ShouldNotBeNull();
compressed.Length.ShouldBeGreaterThan(0);
// Snappy output begins with a varint for the original length — not the same raw bytes.
compressed.ShouldNotBe(payload);
}
// ── 2 ──────────────────────────────────────────────────────────────────────
[Fact]
public void Decompress_RoundTrip_MatchesOriginal()
{
// Arrange
var original = Encoding.UTF8.GetBytes(
"""{"server":{"name":"test","id":"XYZ"},"stats":{"cpu":0.5,"mem":1048576}}""");
// Act
var compressed = EventCompressor.Compress(original);
var decompressed = EventCompressor.Decompress(compressed);
// Assert
decompressed.ShouldBe(original);
}
// ── 3 ──────────────────────────────────────────────────────────────────────
[Fact]
public void ShouldCompress_BelowThreshold_ReturnsFalse()
{
// 100 bytes is well below the default 256-byte threshold.
EventCompressor.ShouldCompress(100).ShouldBeFalse();
}
// ── 4 ──────────────────────────────────────────────────────────────────────
[Fact]
public void ShouldCompress_AboveThreshold_ReturnsTrue()
{
// 500 bytes exceeds the default 256-byte threshold.
EventCompressor.ShouldCompress(500).ShouldBeTrue();
}
// ── 5 ──────────────────────────────────────────────────────────────────────
[Fact]
public void CompressIfBeneficial_SmallPayload_NotCompressed()
{
// Arrange — 50 bytes is below the 256-byte threshold.
var payload = Encoding.UTF8.GetBytes("small");
// Act
var (data, compressed) = EventCompressor.CompressIfBeneficial(payload);
// Assert
compressed.ShouldBeFalse();
data.ShouldBe(payload);
}
// ── 6 ──────────────────────────────────────────────────────────────────────
[Fact]
public void CompressIfBeneficial_LargePayload_Compressed()
{
// Arrange — build a payload well above the 256-byte threshold.
var largeJson = """{"server":{"name":"s1"},"data":""" + new string('x', 500) + "}";
var payload = Encoding.UTF8.GetBytes(largeJson);
payload.Length.ShouldBeGreaterThan(256);
// Act
var (data, isCompressed) = EventCompressor.CompressIfBeneficial(payload);
// Assert
isCompressed.ShouldBeTrue();
// The returned bytes should decompress back to the original.
var restored = EventCompressor.Decompress(data);
restored.ShouldBe(payload);
}
// ── 7 ──────────────────────────────────────────────────────────────────────
[Fact]
public void GetCompressionRatio_Calculates()
{
// 100 / 200 = 0.5
var ratio = EventCompressor.GetCompressionRatio(originalSize: 200, compressedSize: 100);
ratio.ShouldBe(0.5, tolerance: 0.001);
}
// ── 8 ──────────────────────────────────────────────────────────────────────
[Fact]
public void TotalCompressed_IncrementedOnCompress()
{
// Arrange — stats were reset in constructor.
EventCompressor.TotalCompressed.ShouldBe(0L);
var largePayload = Encoding.UTF8.GetBytes(new string('a', 512));
// Act — two calls that exceed threshold.
EventCompressor.CompressIfBeneficial(largePayload);
EventCompressor.CompressIfBeneficial(largePayload);
// Assert
EventCompressor.TotalCompressed.ShouldBe(2L);
}
// ── 9 ──────────────────────────────────────────────────────────────────────
[Fact]
public void BytesSaved_TracksCorrectly()
{
// Arrange
// Use a highly-compressible payload so savings are guaranteed.
var payload = Encoding.UTF8.GetBytes(new string('z', 1024));
// Act
EventCompressor.CompressIfBeneficial(payload);
// Assert — compressed version of 1 024 repeated bytes should be much smaller.
EventCompressor.BytesSaved.ShouldBeGreaterThan(0L);
// BytesSaved = original - compressed; should be less than original size.
EventCompressor.BytesSaved.ShouldBeLessThan(payload.Length);
}
// ── 10 ─────────────────────────────────────────────────────────────────────
[Fact]
public void ResetStats_ClearsAll()
{
// Arrange — produce some stats first.
var largePayload = Encoding.UTF8.GetBytes(new string('b', 512));
EventCompressor.CompressIfBeneficial(largePayload);
EventCompressor.TotalCompressed.ShouldBeGreaterThan(0L);
// Act
EventCompressor.ResetStats();
// Assert
EventCompressor.TotalCompressed.ShouldBe(0L);
EventCompressor.TotalUncompressed.ShouldBe(0L);
EventCompressor.BytesSaved.ShouldBe(0L);
}
[Fact]
public void GetAcceptEncoding_ParsesSnappyAndGzip()
{
EventCompressor.GetAcceptEncoding("gzip, snappy").ShouldBe(EventCompressionType.Snappy);
EventCompressor.GetAcceptEncoding("gzip").ShouldBe(EventCompressionType.Gzip);
EventCompressor.GetAcceptEncoding("br").ShouldBe(EventCompressionType.Unsupported);
EventCompressor.GetAcceptEncoding(null).ShouldBe(EventCompressionType.None);
}
[Fact]
public void CompressionHeaderConstants_MatchGo()
{
EventCompressor.AcceptEncodingHeader.ShouldBe("Accept-Encoding");
EventCompressor.ContentEncodingHeader.ShouldBe("Content-Encoding");
}
[Fact]
public void CompressAndDecompress_Gzip_RoundTrip_MatchesOriginal()
{
var payload = Encoding.UTF8.GetBytes("""{"server":"s1","data":"gzip-payload"}""");
var compressed = EventCompressor.Compress(payload, EventCompressionType.Gzip);
var restored = EventCompressor.Decompress(compressed, EventCompressionType.Gzip);
restored.ShouldBe(payload);
}
}

View File

@@ -0,0 +1,943 @@
// Port of Go server/events_test.go — system event DTO and subject parity tests.
// Reference: golang/nats-server/server/events_test.go
//
// Tests cover: ConnectEventMsg, DisconnectEventMsg, ServerStatsMsg,
// AccountNumConns, AuthErrorEventMsg, ShutdownEventMsg serialization,
// event subject pattern formatting, event filtering by tag/server ID,
// and HealthZ status code mapping.
using System.Text.Json;
using NATS.Server.Events;
namespace NATS.Server.Monitoring.Tests.Events;
/// <summary>
/// Parity tests ported from Go server/events_test.go exercising
/// system event DTOs, JSON serialization shapes, event subjects,
/// and event filtering logic.
/// </summary>
public class EventGoParityTests
{
// ========================================================================
// ConnectEventMsg serialization
// Go reference: events_test.go TestSystemAccountNewConnection
// ========================================================================
[Fact]
public void ConnectEventMsg_JsonShape_MatchesGo()
{
// Go: TestSystemAccountNewConnection — verifies connect event JSON shape.
var evt = new ConnectEventMsg
{
Id = "evt-001",
Time = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
Server = new EventServerInfo
{
Name = "test-server",
Id = "NSVR001",
Cluster = "test-cluster",
Version = "2.10.0",
},
Client = new EventClientInfo
{
Id = 42,
Account = "$G",
User = "alice",
Name = "test-client",
Lang = "csharp",
Version = "1.0",
},
};
var json = JsonSerializer.Serialize(evt);
json.ShouldContain("\"type\":");
json.ShouldContain(ConnectEventMsg.EventType);
json.ShouldContain("\"server\":");
json.ShouldContain("\"client\":");
json.ShouldContain("\"id\":\"evt-001\"");
}
[Fact]
public void ConnectEventMsg_EventType_Constant()
{
// Go: connect event type string.
ConnectEventMsg.EventType.ShouldBe("io.nats.server.advisory.v1.client_connect");
}
[Fact]
public void ConnectEventMsg_DefaultType_MatchesConstant()
{
var evt = new ConnectEventMsg();
evt.Type.ShouldBe(ConnectEventMsg.EventType);
}
// ========================================================================
// DisconnectEventMsg serialization
// Go reference: events_test.go TestSystemAccountNewConnection (disconnect part)
// ========================================================================
[Fact]
public void DisconnectEventMsg_JsonShape_MatchesGo()
{
// Go: TestSystemAccountNewConnection — verifies disconnect event includes
// sent/received stats and reason.
var evt = new DisconnectEventMsg
{
Id = "evt-002",
Time = DateTime.UtcNow,
Server = new EventServerInfo { Name = "test-server", Id = "NSVR001" },
Client = new EventClientInfo { Id = 42, Account = "$G" },
Sent = new DataStats { Msgs = 100, Bytes = 10240 },
Received = new DataStats { Msgs = 50, Bytes = 5120 },
Reason = "Client Closed",
};
var json = JsonSerializer.Serialize(evt);
json.ShouldContain("\"type\":");
json.ShouldContain(DisconnectEventMsg.EventType);
json.ShouldContain("\"sent\":");
json.ShouldContain("\"received\":");
json.ShouldContain("\"reason\":");
}
[Fact]
public void DisconnectEventMsg_EventType_Constant()
{
DisconnectEventMsg.EventType.ShouldBe("io.nats.server.advisory.v1.client_disconnect");
}
[Fact]
public void DisconnectEventMsg_Reason_ClientClosed()
{
// Go: TestSystemAccountDisconnectBadLogin — reason is captured on disconnect.
var evt = new DisconnectEventMsg { Reason = "Client Closed" };
evt.Reason.ShouldBe("Client Closed");
}
[Fact]
public void DisconnectEventMsg_Reason_AuthViolation()
{
// Go: TestSystemAccountDisconnectBadLogin — bad login reason.
var evt = new DisconnectEventMsg { Reason = "Authentication Violation" };
evt.Reason.ShouldBe("Authentication Violation");
}
// ========================================================================
// DataStats
// Go reference: events_test.go TestSystemAccountingWithLeafNodes
// ========================================================================
[Fact]
public void DataStats_JsonSerialization()
{
// Go: TestSystemAccountingWithLeafNodes — verifies sent/received stats structure.
var stats = new DataStats
{
Msgs = 1000,
Bytes = 65536,
Routes = new MsgBytesStats { Msgs = 200, Bytes = 10240 },
Gateways = new MsgBytesStats { Msgs = 50, Bytes = 2048 },
Leafs = new MsgBytesStats { Msgs = 100, Bytes = 5120 },
};
var json = JsonSerializer.Serialize(stats);
json.ShouldContain("\"msgs\":");
json.ShouldContain("\"bytes\":");
json.ShouldContain("\"routes\":");
json.ShouldContain("\"gateways\":");
json.ShouldContain("\"leafs\":");
}
[Fact]
public void DataStats_NullSubStats_OmittedFromJson()
{
// Go: When no routes/gateways/leafs, those fields are omitted (omitempty).
var stats = new DataStats { Msgs = 100, Bytes = 1024 };
var json = JsonSerializer.Serialize(stats);
json.ShouldNotContain("\"routes\":");
json.ShouldNotContain("\"gateways\":");
json.ShouldNotContain("\"leafs\":");
}
// ========================================================================
// AccountNumConns
// Go reference: events_test.go TestAccountReqMonitoring
// ========================================================================
[Fact]
public void AccountNumConns_JsonShape_MatchesGo()
{
// Go: TestAccountReqMonitoring — verifies account connection count event shape.
var evt = new AccountNumConns
{
Id = "evt-003",
Time = DateTime.UtcNow,
Server = new EventServerInfo { Name = "test-server", Id = "NSVR001" },
AccountName = "MYACCOUNT",
Connections = 5,
LeafNodes = 2,
TotalConnections = 10,
NumSubscriptions = 42,
Sent = new DataStats { Msgs = 500, Bytes = 25600 },
Received = new DataStats { Msgs = 250, Bytes = 12800 },
};
var json = JsonSerializer.Serialize(evt);
json.ShouldContain("\"type\":");
json.ShouldContain(AccountNumConns.EventType);
json.ShouldContain("\"acc\":");
json.ShouldContain("\"conns\":");
json.ShouldContain("\"leafnodes\":");
json.ShouldContain("\"total_conns\":");
json.ShouldContain("\"num_subscriptions\":");
}
[Fact]
public void AccountNumConns_EventType_Constant()
{
AccountNumConns.EventType.ShouldBe("io.nats.server.advisory.v1.account_connections");
}
[Fact]
public void AccountNumConns_SlowConsumers_IncludedWhenNonZero()
{
var evt = new AccountNumConns { SlowConsumers = 3 };
var json = JsonSerializer.Serialize(evt);
json.ShouldContain("\"slow_consumers\":3");
}
[Fact]
public void AccountNumConns_SlowConsumers_OmittedWhenZero()
{
// Go: omitempty behavior — zero slow_consumers omitted.
var evt = new AccountNumConns { SlowConsumers = 0 };
var json = JsonSerializer.Serialize(evt);
json.ShouldNotContain("\"slow_consumers\":");
}
// ========================================================================
// ServerStatsMsg
// Go reference: events_test.go TestServerEventsPingStatsZDedicatedRecvQ
// ========================================================================
[Fact]
public void ServerStatsMsg_JsonShape_MatchesGo()
{
// Go: TestServerEventsPingStatsZDedicatedRecvQ — verifies server stats shape.
var msg = new ServerStatsMsg
{
Server = new EventServerInfo
{
Name = "test-server",
Id = "NSVR001",
Version = "2.10.0",
JetStream = true,
},
Stats = new ServerStatsData
{
Start = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
Mem = 134217728,
Cores = 8,
Cpu = 12.5,
Connections = 10,
TotalConnections = 100,
ActiveAccounts = 5,
Subscriptions = 42,
Sent = new DataStats { Msgs = 1000, Bytes = 65536 },
Received = new DataStats { Msgs = 500, Bytes = 32768 },
InMsgs = 500,
OutMsgs = 1000,
InBytes = 32768,
OutBytes = 65536,
},
};
var json = JsonSerializer.Serialize(msg);
json.ShouldContain("\"server\":");
json.ShouldContain("\"statsz\":");
json.ShouldContain("\"mem\":");
json.ShouldContain("\"cores\":");
json.ShouldContain("\"connections\":");
json.ShouldContain("\"total_connections\":");
json.ShouldContain("\"subscriptions\":");
json.ShouldContain("\"in_msgs\":");
json.ShouldContain("\"out_msgs\":");
}
[Fact]
public void ServerStatsData_SlowConsumerStats_JsonShape()
{
// Go: TestServerEventsPingStatsSlowConsumersStats — breakdown by type.
var data = new ServerStatsData
{
SlowConsumers = 10,
SlowConsumerStats = new NATS.Server.Events.SlowConsumersStats
{
Clients = 5,
Routes = 2,
Gateways = 1,
Leafs = 2,
},
};
var json = JsonSerializer.Serialize(data);
json.ShouldContain("\"slow_consumers\":10");
json.ShouldContain("\"slow_consumer_stats\":");
json.ShouldContain("\"clients\":5");
json.ShouldContain("\"routes\":2");
}
[Fact]
public void ServerStatsData_StaleConnectionStats_JsonShape()
{
// Go: TestServerEventsPingStatsStaleConnectionStats — stale conn breakdown.
var data = new ServerStatsData
{
StaleConnections = 7,
StaleConnectionStats = new NATS.Server.Events.StaleConnectionStats
{
Clients = 3,
Routes = 1,
Gateways = 2,
Leafs = 1,
},
};
var json = JsonSerializer.Serialize(data);
json.ShouldContain("\"stale_connections\":7");
json.ShouldContain("\"stale_connection_stats\":");
}
[Fact]
public void ServerStatsData_RouteStats_JsonShape()
{
// Go: TestServerEventsPingStatsZDedicatedRecvQ — route stats in statsz.
var data = new ServerStatsData
{
Routes =
[
new RouteStat
{
Id = 100,
Name = "route-1",
Sent = new DataStats { Msgs = 200, Bytes = 10240 },
Received = new DataStats { Msgs = 150, Bytes = 7680 },
Pending = 5,
},
],
};
var json = JsonSerializer.Serialize(data);
json.ShouldContain("\"routes\":");
json.ShouldContain("\"rid\":100");
json.ShouldContain("\"pending\":5");
}
[Fact]
public void ServerStatsData_GatewayStats_JsonShape()
{
// Go: TestGatewayNameClientInfo — gateway stats in statsz.
var data = new ServerStatsData
{
Gateways =
[
new GatewayStat
{
Id = 200,
Name = "gw-east",
Sent = new DataStats { Msgs = 500, Bytes = 25600 },
Received = new DataStats { Msgs = 300, Bytes = 15360 },
InboundConnections = 3,
},
],
};
var json = JsonSerializer.Serialize(data);
json.ShouldContain("\"gateways\":");
json.ShouldContain("\"gwid\":200");
json.ShouldContain("\"inbound_connections\":3");
}
// ========================================================================
// ShutdownEventMsg
// Go reference: events_test.go TestServerEventsLDMKick
// ========================================================================
[Fact]
public void ShutdownEventMsg_JsonShape_MatchesGo()
{
// Go: ShutdownEventMsg includes server info and reason.
var evt = new ShutdownEventMsg
{
Server = new EventServerInfo { Name = "test-server", Id = "NSVR001" },
Reason = "process exit",
};
var json = JsonSerializer.Serialize(evt);
json.ShouldContain("\"server\":");
json.ShouldContain("\"reason\":");
json.ShouldContain("\"process exit\"");
}
// ========================================================================
// LameDuckEventMsg
// Go reference: events_test.go TestServerEventsLDMKick
// ========================================================================
[Fact]
public void LameDuckEventMsg_JsonShape_MatchesGo()
{
// Go: TestServerEventsLDMKick — lame duck event emitted before shutdown.
var evt = new LameDuckEventMsg
{
Server = new EventServerInfo { Name = "test-server", Id = "NSVR001" },
};
var json = JsonSerializer.Serialize(evt);
json.ShouldContain("\"server\":");
json.ShouldContain("\"name\":\"test-server\"");
}
// ========================================================================
// AuthErrorEventMsg
// Go reference: events_test.go TestSystemAccountDisconnectBadLogin
// ========================================================================
[Fact]
public void AuthErrorEventMsg_JsonShape_MatchesGo()
{
// Go: TestSystemAccountDisconnectBadLogin — auth error advisory.
var evt = new AuthErrorEventMsg
{
Id = "evt-004",
Time = DateTime.UtcNow,
Server = new EventServerInfo { Name = "test-server", Id = "NSVR001" },
Client = new EventClientInfo { Id = 99, Host = "192.168.1.100" },
Reason = "Authorization Violation",
};
var json = JsonSerializer.Serialize(evt);
json.ShouldContain("\"type\":");
json.ShouldContain(AuthErrorEventMsg.EventType);
json.ShouldContain("\"reason\":");
json.ShouldContain("\"Authorization Violation\"");
}
[Fact]
public void AuthErrorEventMsg_EventType_Constant()
{
AuthErrorEventMsg.EventType.ShouldBe("io.nats.server.advisory.v1.client_auth");
}
// ========================================================================
// OcspPeerRejectEventMsg
// Go reference: events.go OCSPPeerRejectEventMsg struct
// ========================================================================
[Fact]
public void OcspPeerRejectEventMsg_JsonShape_MatchesGo()
{
var evt = new OcspPeerRejectEventMsg
{
Id = "evt-005",
Time = DateTime.UtcNow,
Kind = "client",
Server = new EventServerInfo { Name = "test-server", Id = "NSVR001" },
Reason = "OCSP certificate revoked",
};
var json = JsonSerializer.Serialize(evt);
json.ShouldContain("\"type\":");
json.ShouldContain(OcspPeerRejectEventMsg.EventType);
json.ShouldContain("\"kind\":\"client\"");
json.ShouldContain("\"reason\":");
}
[Fact]
public void OcspPeerRejectEventMsg_EventType_Constant()
{
OcspPeerRejectEventMsg.EventType.ShouldBe("io.nats.server.advisory.v1.ocsp_peer_reject");
}
// ========================================================================
// AccNumConnsReq
// Go reference: events.go accNumConnsReq
// ========================================================================
[Fact]
public void AccNumConnsReq_JsonShape_MatchesGo()
{
var req = new AccNumConnsReq
{
Server = new EventServerInfo { Name = "test-server", Id = "NSVR001" },
Account = "$G",
};
var json = JsonSerializer.Serialize(req);
json.ShouldContain("\"server\":");
json.ShouldContain("\"acc\":\"$G\"");
}
// ========================================================================
// EventServerInfo
// Go reference: events_test.go TestServerEventsFilteredByTag
// ========================================================================
[Fact]
public void EventServerInfo_Tags_Serialized()
{
// Go: TestServerEventsFilteredByTag — server info includes tags for filtering.
var info = new EventServerInfo
{
Name = "test-server",
Id = "NSVR001",
Tags = ["region:us-east-1", "env:production"],
};
var json = JsonSerializer.Serialize(info);
json.ShouldContain("\"tags\":");
json.ShouldContain("\"region:us-east-1\"");
json.ShouldContain("\"env:production\"");
}
[Fact]
public void EventServerInfo_NullTags_OmittedFromJson()
{
// Go: omitempty — nil tags are not serialized.
var info = new EventServerInfo { Name = "test-server", Id = "NSVR001" };
var json = JsonSerializer.Serialize(info);
json.ShouldNotContain("\"tags\":");
}
[Fact]
public void EventServerInfo_Metadata_Serialized()
{
var info = new EventServerInfo
{
Name = "test-server",
Id = "NSVR001",
Metadata = new Dictionary<string, string>
{
["cloud"] = "aws",
["zone"] = "us-east-1a",
},
};
var json = JsonSerializer.Serialize(info);
json.ShouldContain("\"metadata\":");
json.ShouldContain("\"cloud\":");
json.ShouldContain("\"aws\"");
}
[Fact]
public void EventServerInfo_NullMetadata_OmittedFromJson()
{
var info = new EventServerInfo { Name = "test-server", Id = "NSVR001" };
var json = JsonSerializer.Serialize(info);
json.ShouldNotContain("\"metadata\":");
}
[Fact]
public void EventServerInfo_JetStream_IncludedWhenTrue()
{
var info = new EventServerInfo { Name = "s1", Id = "N1", JetStream = true };
var json = JsonSerializer.Serialize(info);
json.ShouldContain("\"jetstream\":true");
}
[Fact]
public void EventServerInfo_JetStream_OmittedWhenFalse()
{
// Go: omitempty — JetStream false is not serialized.
var info = new EventServerInfo { Name = "s1", Id = "N1", JetStream = false };
var json = JsonSerializer.Serialize(info);
json.ShouldNotContain("\"jetstream\":");
}
// ========================================================================
// EventClientInfo
// Go reference: events_test.go TestGatewayNameClientInfo
// ========================================================================
[Fact]
public void EventClientInfo_AllFields_Serialized()
{
// Go: TestGatewayNameClientInfo — client info includes all connection metadata.
var info = new EventClientInfo
{
Id = 42,
Account = "MYACCOUNT",
User = "alice",
Name = "test-client",
Lang = "go",
Version = "1.30.0",
RttNanos = 1_500_000, // 1.5ms
Host = "192.168.1.100",
Kind = "Client",
ClientType = "nats",
Tags = ["role:publisher"],
};
var json = JsonSerializer.Serialize(info);
json.ShouldContain("\"id\":42");
json.ShouldContain("\"acc\":\"MYACCOUNT\"");
json.ShouldContain("\"user\":\"alice\"");
json.ShouldContain("\"name\":\"test-client\"");
json.ShouldContain("\"lang\":\"go\"");
json.ShouldContain("\"rtt\":");
json.ShouldContain("\"kind\":\"Client\"");
}
[Fact]
public void EventClientInfo_MqttClient_Serialized()
{
// Go: MQTT client ID is included in client info when present.
var info = new EventClientInfo
{
Id = 10,
MqttClient = "mqtt-device-42",
};
var json = JsonSerializer.Serialize(info);
json.ShouldContain("\"client_id\":\"mqtt-device-42\"");
}
[Fact]
public void EventClientInfo_NullOptionalFields_OmittedFromJson()
{
// Go: omitempty — null optional fields are not serialized.
var info = new EventClientInfo { Id = 1 };
var json = JsonSerializer.Serialize(info);
json.ShouldNotContain("\"acc\":");
json.ShouldNotContain("\"user\":");
json.ShouldNotContain("\"name\":");
json.ShouldNotContain("\"lang\":");
json.ShouldNotContain("\"kind\":");
json.ShouldNotContain("\"tags\":");
}
// ========================================================================
// Event Subject Patterns
// Go reference: events.go subject constants
// ========================================================================
[Fact]
public void EventSubjects_ConnectEvent_Format()
{
// Go: $SYS.ACCOUNT.%s.CONNECT
var subject = string.Format(EventSubjects.ConnectEvent, "$G");
subject.ShouldBe("$SYS.ACCOUNT.$G.CONNECT");
}
[Fact]
public void EventSubjects_DisconnectEvent_Format()
{
// Go: $SYS.ACCOUNT.%s.DISCONNECT
var subject = string.Format(EventSubjects.DisconnectEvent, "$G");
subject.ShouldBe("$SYS.ACCOUNT.$G.DISCONNECT");
}
[Fact]
public void EventSubjects_AccountConns_Format()
{
// Go: $SYS.ACCOUNT.%s.SERVER.CONNS (new format)
var subject = string.Format(EventSubjects.AccountConnsNew, "MYACCOUNT");
subject.ShouldBe("$SYS.ACCOUNT.MYACCOUNT.SERVER.CONNS");
}
[Fact]
public void EventSubjects_AccountConnsOld_Format()
{
// Go: $SYS.SERVER.ACCOUNT.%s.CONNS (old format for backward compat)
var subject = string.Format(EventSubjects.AccountConnsOld, "MYACCOUNT");
subject.ShouldBe("$SYS.SERVER.ACCOUNT.MYACCOUNT.CONNS");
}
[Fact]
public void EventSubjects_ServerStats_Format()
{
// Go: $SYS.SERVER.%s.STATSZ
var subject = string.Format(EventSubjects.ServerStats, "NSVR001");
subject.ShouldBe("$SYS.SERVER.NSVR001.STATSZ");
}
[Fact]
public void EventSubjects_ServerShutdown_Format()
{
// Go: $SYS.SERVER.%s.SHUTDOWN
var subject = string.Format(EventSubjects.ServerShutdown, "NSVR001");
subject.ShouldBe("$SYS.SERVER.NSVR001.SHUTDOWN");
}
[Fact]
public void EventSubjects_ServerLameDuck_Format()
{
// Go: $SYS.SERVER.%s.LAMEDUCK
var subject = string.Format(EventSubjects.ServerLameDuck, "NSVR001");
subject.ShouldBe("$SYS.SERVER.NSVR001.LAMEDUCK");
}
[Fact]
public void EventSubjects_AuthError_Format()
{
// Go: $SYS.SERVER.%s.CLIENT.AUTH.ERR
var subject = string.Format(EventSubjects.AuthError, "NSVR001");
subject.ShouldBe("$SYS.SERVER.NSVR001.CLIENT.AUTH.ERR");
}
[Fact]
public void EventSubjects_AuthErrorAccount_IsConstant()
{
// Go: $SYS.ACCOUNT.CLIENT.AUTH.ERR (no server ID interpolation)
EventSubjects.AuthErrorAccount.ShouldBe("$SYS.ACCOUNT.CLIENT.AUTH.ERR");
}
[Fact]
public void EventSubjects_ServerPing_Format()
{
// Go: $SYS.REQ.SERVER.PING.%s (e.g., STATSZ, VARZ)
var subject = string.Format(EventSubjects.ServerPing, "STATSZ");
subject.ShouldBe("$SYS.REQ.SERVER.PING.STATSZ");
}
[Fact]
public void EventSubjects_ServerReq_Format()
{
// Go: $SYS.REQ.SERVER.%s.%s (server ID + request type)
var subject = string.Format(EventSubjects.ServerReq, "NSVR001", "VARZ");
subject.ShouldBe("$SYS.REQ.SERVER.NSVR001.VARZ");
}
[Fact]
public void EventSubjects_AccountReq_Format()
{
// Go: $SYS.REQ.ACCOUNT.%s.%s (account + request type)
var subject = string.Format(EventSubjects.AccountReq, "MYACCOUNT", "CONNZ");
subject.ShouldBe("$SYS.REQ.ACCOUNT.MYACCOUNT.CONNZ");
}
// ========================================================================
// Event filtering by tag
// Go reference: events_test.go TestServerEventsFilteredByTag
// ========================================================================
[Fact]
public void EventServerInfo_TagFiltering_MatchesTag()
{
// Go: TestServerEventsFilteredByTag — servers can be filtered by tag value.
var server = new EventServerInfo
{
Name = "s1",
Id = "NSVR001",
Tags = ["region:us-east-1", "env:prod"],
};
// Simulate filtering: check if server has a specific tag.
server.Tags.ShouldContain("region:us-east-1");
server.Tags.ShouldContain("env:prod");
server.Tags.ShouldNotContain("region:eu-west-1");
}
[Fact]
public void EventServerInfo_TagFiltering_EmptyTags_NoMatch()
{
// Go: TestServerEventsFilteredByTag — server with no tags does not match any filter.
var server = new EventServerInfo { Name = "s1", Id = "NSVR001" };
server.Tags.ShouldBeNull();
}
[Fact]
public void EventServerInfo_FilterByServerId()
{
// Go: TestServerEventsPingStatsZFilter — filter stats events by server ID.
var servers = new[]
{
new EventServerInfo { Name = "s1", Id = "NSVR001" },
new EventServerInfo { Name = "s2", Id = "NSVR002" },
new EventServerInfo { Name = "s3", Id = "NSVR003" },
};
var filtered = servers.Where(s => s.Id == "NSVR002").ToArray();
filtered.Length.ShouldBe(1);
filtered[0].Name.ShouldBe("s2");
}
[Fact]
public void EventServerInfo_FilterByServerId_NoMatch()
{
// Go: TestServerEventsPingStatsZFailFilter — non-existent server ID returns nothing.
var servers = new[]
{
new EventServerInfo { Name = "s1", Id = "NSVR001" },
};
var filtered = servers.Where(s => s.Id == "NONEXISTENT").ToArray();
filtered.Length.ShouldBe(0);
}
// ========================================================================
// Event JSON roundtrip via source-generated context
// Go reference: events_test.go TestServerEventsReceivedByQSubs
// ========================================================================
[Fact]
public void ConnectEventMsg_RoundTrip_ViaContext()
{
// Go: TestServerEventsReceivedByQSubs — events received and parsed correctly.
var original = new ConnectEventMsg
{
Id = "roundtrip-001",
Time = new DateTime(2024, 6, 15, 12, 0, 0, DateTimeKind.Utc),
Server = new EventServerInfo { Name = "s1", Id = "NSVR001" },
Client = new EventClientInfo { Id = 42, Account = "$G", User = "alice" },
};
var json = JsonSerializer.Serialize(original, EventJsonContext.Default.ConnectEventMsg);
var deserialized = JsonSerializer.Deserialize(json, EventJsonContext.Default.ConnectEventMsg);
deserialized.ShouldNotBeNull();
deserialized!.Id.ShouldBe("roundtrip-001");
deserialized.Type.ShouldBe(ConnectEventMsg.EventType);
deserialized.Server.Name.ShouldBe("s1");
deserialized.Client.Id.ShouldBe(42UL);
deserialized.Client.Account.ShouldBe("$G");
}
[Fact]
public void DisconnectEventMsg_RoundTrip_ViaContext()
{
var original = new DisconnectEventMsg
{
Id = "roundtrip-002",
Time = DateTime.UtcNow,
Server = new EventServerInfo { Name = "s1", Id = "NSVR001" },
Client = new EventClientInfo { Id = 99 },
Sent = new DataStats { Msgs = 100, Bytes = 1024 },
Received = new DataStats { Msgs = 50, Bytes = 512 },
Reason = "Client Closed",
};
var json = JsonSerializer.Serialize(original, EventJsonContext.Default.DisconnectEventMsg);
var deserialized = JsonSerializer.Deserialize(json, EventJsonContext.Default.DisconnectEventMsg);
deserialized.ShouldNotBeNull();
deserialized!.Reason.ShouldBe("Client Closed");
deserialized.Sent.Msgs.ShouldBe(100);
deserialized.Received.Bytes.ShouldBe(512);
}
[Fact]
public void ServerStatsMsg_RoundTrip_ViaContext()
{
var original = new ServerStatsMsg
{
Server = new EventServerInfo { Name = "s1", Id = "NSVR001", JetStream = true },
Stats = new ServerStatsData
{
Mem = 134217728,
Cores = 8,
Connections = 10,
Subscriptions = 42,
Sent = new DataStats { Msgs = 1000, Bytes = 65536 },
Received = new DataStats { Msgs = 500, Bytes = 32768 },
},
};
var json = JsonSerializer.Serialize(original, EventJsonContext.Default.ServerStatsMsg);
var deserialized = JsonSerializer.Deserialize(json, EventJsonContext.Default.ServerStatsMsg);
deserialized.ShouldNotBeNull();
deserialized!.Server.JetStream.ShouldBeTrue();
deserialized.Stats.Mem.ShouldBe(134217728);
deserialized.Stats.Connections.ShouldBe(10);
}
[Fact]
public void AccountNumConns_RoundTrip_ViaContext()
{
var original = new AccountNumConns
{
Id = "roundtrip-004",
Time = DateTime.UtcNow,
Server = new EventServerInfo { Name = "s1", Id = "NSVR001" },
AccountName = "$G",
Connections = 5,
TotalConnections = 20,
NumSubscriptions = 15,
};
var json = JsonSerializer.Serialize(original, EventJsonContext.Default.AccountNumConns);
var deserialized = JsonSerializer.Deserialize(json, EventJsonContext.Default.AccountNumConns);
deserialized.ShouldNotBeNull();
deserialized!.AccountName.ShouldBe("$G");
deserialized.Connections.ShouldBe(5);
deserialized.TotalConnections.ShouldBe(20);
}
[Fact]
public void AuthErrorEventMsg_RoundTrip_ViaContext()
{
var original = new AuthErrorEventMsg
{
Id = "roundtrip-005",
Time = DateTime.UtcNow,
Server = new EventServerInfo { Name = "s1", Id = "NSVR001" },
Client = new EventClientInfo { Id = 99, Host = "10.0.0.1" },
Reason = "Authorization Violation",
};
var json = JsonSerializer.Serialize(original, EventJsonContext.Default.AuthErrorEventMsg);
var deserialized = JsonSerializer.Deserialize(json, EventJsonContext.Default.AuthErrorEventMsg);
deserialized.ShouldNotBeNull();
deserialized!.Reason.ShouldBe("Authorization Violation");
deserialized.Type.ShouldBe(AuthErrorEventMsg.EventType);
}
// ========================================================================
// Event subject $SYS prefix validation
// Go reference: events.go — all system subjects start with $SYS
// ========================================================================
[Fact]
public void AllEventSubjects_StartWithSysDollarPrefix()
{
// Go: All system event subjects must start with $SYS.
EventSubjects.ConnectEvent.ShouldStartWith("$SYS.");
EventSubjects.DisconnectEvent.ShouldStartWith("$SYS.");
EventSubjects.AccountConnsNew.ShouldStartWith("$SYS.");
EventSubjects.AccountConnsOld.ShouldStartWith("$SYS.");
EventSubjects.ServerStats.ShouldStartWith("$SYS.");
EventSubjects.ServerShutdown.ShouldStartWith("$SYS.");
EventSubjects.ServerLameDuck.ShouldStartWith("$SYS.");
EventSubjects.AuthError.ShouldStartWith("$SYS.");
EventSubjects.AuthErrorAccount.ShouldStartWith("$SYS.");
EventSubjects.ServerPing.ShouldStartWith("$SYS.");
EventSubjects.ServerReq.ShouldStartWith("$SYS.");
EventSubjects.AccountReq.ShouldStartWith("$SYS.");
EventSubjects.InboxResponse.ShouldStartWith("$SYS.");
}
}

View File

@@ -0,0 +1,469 @@
using System.Text.Json;
using NATS.Server.Events;
namespace NATS.Server.Monitoring.Tests.Events;
/// <summary>
/// Tests that all event DTOs have complete JSON fields matching Go's output.
/// Go reference: events.go:100-300 — TypedEvent, ServerInfo, ClientInfo,
/// DataStats, ServerStats, ConnectEventMsg, DisconnectEventMsg, AccountNumConns.
/// </summary>
public class EventPayloadTests
{
// --- EventServerInfo ---
[Fact]
public void EventServerInfo_serializes_all_fields_matching_Go()
{
var info = new EventServerInfo
{
Name = "test-server",
Host = "127.0.0.1",
Id = "ABCDEF123456",
Cluster = "test-cluster",
Domain = "test-domain",
Version = "2.10.0",
Tags = ["tag1", "tag2"],
Metadata = new Dictionary<string, string> { ["env"] = "test" },
JetStream = true,
Flags = 1,
Seq = 42,
Time = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc),
};
var json = JsonSerializer.Serialize(info);
var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
root.GetProperty("name").GetString().ShouldBe("test-server");
root.GetProperty("host").GetString().ShouldBe("127.0.0.1");
root.GetProperty("id").GetString().ShouldBe("ABCDEF123456");
root.GetProperty("cluster").GetString().ShouldBe("test-cluster");
root.GetProperty("domain").GetString().ShouldBe("test-domain");
root.GetProperty("ver").GetString().ShouldBe("2.10.0");
root.GetProperty("tags").GetArrayLength().ShouldBe(2);
root.GetProperty("metadata").GetProperty("env").GetString().ShouldBe("test");
root.GetProperty("jetstream").GetBoolean().ShouldBeTrue();
root.GetProperty("flags").GetUInt64().ShouldBe(1UL);
root.GetProperty("seq").GetUInt64().ShouldBe(42UL);
root.GetProperty("time").GetDateTime().Year.ShouldBe(2025);
}
[Fact]
public void EventServerInfo_omits_null_optional_fields()
{
var info = new EventServerInfo
{
Name = "s",
Id = "ID",
};
var json = JsonSerializer.Serialize(info);
var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
root.TryGetProperty("cluster", out _).ShouldBeFalse();
root.TryGetProperty("domain", out _).ShouldBeFalse();
root.TryGetProperty("tags", out _).ShouldBeFalse();
root.TryGetProperty("metadata", out _).ShouldBeFalse();
}
// --- EventClientInfo ---
[Fact]
public void EventClientInfo_serializes_all_fields_matching_Go()
{
var ci = new EventClientInfo
{
Start = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc),
Stop = new DateTime(2025, 1, 1, 1, 0, 0, DateTimeKind.Utc),
Host = "10.0.0.1",
Id = 99,
Account = "$G",
Service = "orders",
User = "admin",
Name = "my-client",
Lang = "go",
Version = "1.30.0",
RttNanos = 5_000_000, // 5ms
Server = "srv-1",
Cluster = "cluster-east",
Alternates = ["alt1", "alt2"],
Jwt = "eyJ...",
IssuerKey = "OABC...",
NameTag = "test-tag",
Tags = ["dev"],
Kind = "Client",
ClientType = "nats",
MqttClient = "mqtt-abc",
Nonce = "nonce123",
};
var json = JsonSerializer.Serialize(ci);
var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
root.GetProperty("host").GetString().ShouldBe("10.0.0.1");
root.GetProperty("id").GetUInt64().ShouldBe(99UL);
root.GetProperty("acc").GetString().ShouldBe("$G");
root.GetProperty("svc").GetString().ShouldBe("orders");
root.GetProperty("user").GetString().ShouldBe("admin");
root.GetProperty("name").GetString().ShouldBe("my-client");
root.GetProperty("lang").GetString().ShouldBe("go");
root.GetProperty("ver").GetString().ShouldBe("1.30.0");
root.GetProperty("rtt").GetInt64().ShouldBe(5_000_000);
root.GetProperty("server").GetString().ShouldBe("srv-1");
root.GetProperty("cluster").GetString().ShouldBe("cluster-east");
root.GetProperty("alts").GetArrayLength().ShouldBe(2);
root.GetProperty("jwt").GetString().ShouldBe("eyJ...");
root.GetProperty("issuer_key").GetString().ShouldBe("OABC...");
root.GetProperty("name_tag").GetString().ShouldBe("test-tag");
root.GetProperty("tags").GetArrayLength().ShouldBe(1);
root.GetProperty("kind").GetString().ShouldBe("Client");
root.GetProperty("client_type").GetString().ShouldBe("nats");
root.GetProperty("client_id").GetString().ShouldBe("mqtt-abc");
root.GetProperty("nonce").GetString().ShouldBe("nonce123");
}
[Fact]
public void EventClientInfo_omits_null_optional_fields()
{
var ci = new EventClientInfo { Id = 1 };
var json = JsonSerializer.Serialize(ci);
var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
root.TryGetProperty("svc", out _).ShouldBeFalse();
root.TryGetProperty("user", out _).ShouldBeFalse();
root.TryGetProperty("server", out _).ShouldBeFalse();
root.TryGetProperty("cluster", out _).ShouldBeFalse();
root.TryGetProperty("alts", out _).ShouldBeFalse();
root.TryGetProperty("jwt", out _).ShouldBeFalse();
root.TryGetProperty("issuer_key", out _).ShouldBeFalse();
root.TryGetProperty("nonce", out _).ShouldBeFalse();
}
// --- DataStats ---
[Fact]
public void DataStats_serializes_with_optional_sub_stats()
{
var ds = new DataStats
{
Msgs = 100,
Bytes = 2048,
Gateways = new MsgBytesStats { Msgs = 10, Bytes = 256 },
Routes = new MsgBytesStats { Msgs = 50, Bytes = 1024 },
Leafs = new MsgBytesStats { Msgs = 40, Bytes = 768 },
};
var json = JsonSerializer.Serialize(ds);
var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
root.GetProperty("msgs").GetInt64().ShouldBe(100);
root.GetProperty("bytes").GetInt64().ShouldBe(2048);
root.GetProperty("gateways").GetProperty("msgs").GetInt64().ShouldBe(10);
root.GetProperty("routes").GetProperty("bytes").GetInt64().ShouldBe(1024);
root.GetProperty("leafs").GetProperty("msgs").GetInt64().ShouldBe(40);
}
[Fact]
public void DataStats_omits_null_sub_stats()
{
var ds = new DataStats { Msgs = 5, Bytes = 50 };
var json = JsonSerializer.Serialize(ds);
var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
root.TryGetProperty("gateways", out _).ShouldBeFalse();
root.TryGetProperty("routes", out _).ShouldBeFalse();
root.TryGetProperty("leafs", out _).ShouldBeFalse();
}
// --- ConnectEventMsg ---
[Fact]
public void ConnectEventMsg_has_correct_type_and_required_fields()
{
var evt = new ConnectEventMsg
{
Id = "evt-1",
Time = DateTime.UtcNow,
Server = new EventServerInfo { Name = "s1", Id = "SRV1" },
Client = new EventClientInfo { Id = 42, Name = "test-client" },
};
var json = JsonSerializer.Serialize(evt);
var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
root.GetProperty("type").GetString().ShouldBe("io.nats.server.advisory.v1.client_connect");
root.GetProperty("id").GetString().ShouldBe("evt-1");
root.GetProperty("server").GetProperty("name").GetString().ShouldBe("s1");
root.GetProperty("client").GetProperty("id").GetUInt64().ShouldBe(42UL);
}
// --- DisconnectEventMsg ---
[Fact]
public void DisconnectEventMsg_has_correct_type_and_data_stats()
{
var evt = new DisconnectEventMsg
{
Id = "evt-2",
Time = DateTime.UtcNow,
Server = new EventServerInfo { Name = "s1", Id = "SRV1" },
Client = new EventClientInfo { Id = 42 },
Sent = new DataStats { Msgs = 100, Bytes = 2000 },
Received = new DataStats { Msgs = 50, Bytes = 1000 },
Reason = "Client Closed",
};
var json = JsonSerializer.Serialize(evt);
var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
root.GetProperty("type").GetString().ShouldBe("io.nats.server.advisory.v1.client_disconnect");
root.GetProperty("sent").GetProperty("msgs").GetInt64().ShouldBe(100);
root.GetProperty("received").GetProperty("bytes").GetInt64().ShouldBe(1000);
root.GetProperty("reason").GetString().ShouldBe("Client Closed");
}
// --- AccountNumConns ---
[Fact]
public void AccountNumConns_serializes_all_Go_AccountStat_fields()
{
var evt = new AccountNumConns
{
Id = "evt-3",
Time = DateTime.UtcNow,
Server = new EventServerInfo { Name = "s1", Id = "SRV1" },
AccountName = "$G",
Name = "Global",
Connections = 5,
LeafNodes = 2,
TotalConnections = 100,
NumSubscriptions = 42,
Sent = new DataStats { Msgs = 500, Bytes = 10_000 },
Received = new DataStats { Msgs = 400, Bytes = 8_000 },
SlowConsumers = 1,
};
var json = JsonSerializer.Serialize(evt);
var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
root.GetProperty("type").GetString().ShouldBe("io.nats.server.advisory.v1.account_connections");
root.GetProperty("acc").GetString().ShouldBe("$G");
root.GetProperty("name").GetString().ShouldBe("Global");
root.GetProperty("conns").GetInt32().ShouldBe(5);
root.GetProperty("leafnodes").GetInt32().ShouldBe(2);
root.GetProperty("total_conns").GetInt32().ShouldBe(100);
root.GetProperty("num_subscriptions").GetUInt32().ShouldBe(42u);
root.GetProperty("sent").GetProperty("msgs").GetInt64().ShouldBe(500);
root.GetProperty("received").GetProperty("bytes").GetInt64().ShouldBe(8_000);
root.GetProperty("slow_consumers").GetInt64().ShouldBe(1);
}
// --- ServerStatsMsg ---
[Fact]
public void ServerStatsMsg_has_sent_received_and_breakdown_fields()
{
var msg = new ServerStatsMsg
{
Server = new EventServerInfo { Name = "s1", Id = "SRV1", Seq = 1 },
Stats = new ServerStatsData
{
Start = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc),
Mem = 100_000_000,
Cores = 8,
Cpu = 12.5,
Connections = 10,
TotalConnections = 500,
ActiveAccounts = 3,
Subscriptions = 50,
Sent = new DataStats { Msgs = 1000, Bytes = 50_000 },
Received = new DataStats { Msgs = 800, Bytes = 40_000 },
InMsgs = 800,
OutMsgs = 1000,
InBytes = 40_000,
OutBytes = 50_000,
SlowConsumers = 2,
SlowConsumerStats = new NATS.Server.Events.SlowConsumersStats { Clients = 1, Routes = 1 },
StaleConnections = 3,
StaleConnectionStats = new NATS.Server.Events.StaleConnectionStats { Clients = 2, Leafs = 1 },
ActiveServers = 3,
Routes = [new RouteStat { Id = 1, Name = "r1", Sent = new DataStats { Msgs = 10 }, Received = new DataStats { Msgs = 5 }, Pending = 0 }],
Gateways = [new GatewayStat { Id = 1, Name = "gw1", Sent = new DataStats { Msgs = 20 }, Received = new DataStats { Msgs = 15 }, InboundConnections = 2 }],
},
};
var json = JsonSerializer.Serialize(msg);
var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var stats = root.GetProperty("statsz");
stats.GetProperty("mem").GetInt64().ShouldBe(100_000_000);
stats.GetProperty("cores").GetInt32().ShouldBe(8);
stats.GetProperty("cpu").GetDouble().ShouldBe(12.5);
stats.GetProperty("connections").GetInt32().ShouldBe(10);
stats.GetProperty("total_connections").GetInt64().ShouldBe(500);
stats.GetProperty("active_accounts").GetInt32().ShouldBe(3);
stats.GetProperty("subscriptions").GetInt64().ShouldBe(50);
stats.GetProperty("sent").GetProperty("msgs").GetInt64().ShouldBe(1000);
stats.GetProperty("received").GetProperty("bytes").GetInt64().ShouldBe(40_000);
stats.GetProperty("in_msgs").GetInt64().ShouldBe(800);
stats.GetProperty("out_msgs").GetInt64().ShouldBe(1000);
stats.GetProperty("slow_consumers").GetInt64().ShouldBe(2);
stats.GetProperty("slow_consumer_stats").GetProperty("clients").GetInt64().ShouldBe(1);
stats.GetProperty("stale_connections").GetInt64().ShouldBe(3);
stats.GetProperty("stale_connection_stats").GetProperty("leafs").GetInt64().ShouldBe(1);
stats.GetProperty("active_servers").GetInt32().ShouldBe(3);
stats.GetProperty("routes").GetArrayLength().ShouldBe(1);
stats.GetProperty("routes")[0].GetProperty("rid").GetUInt64().ShouldBe(1UL);
stats.GetProperty("gateways").GetArrayLength().ShouldBe(1);
stats.GetProperty("gateways")[0].GetProperty("name").GetString().ShouldBe("gw1");
}
// --- AuthErrorEventMsg ---
[Fact]
public void AuthErrorEventMsg_has_correct_type()
{
var evt = new AuthErrorEventMsg
{
Id = "evt-4",
Time = DateTime.UtcNow,
Server = new EventServerInfo { Name = "s1", Id = "SRV1" },
Client = new EventClientInfo { Id = 99, Host = "10.0.0.1" },
Reason = "Authorization Violation",
};
var json = JsonSerializer.Serialize(evt);
var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
root.GetProperty("type").GetString().ShouldBe("io.nats.server.advisory.v1.client_auth");
root.GetProperty("reason").GetString().ShouldBe("Authorization Violation");
root.GetProperty("client").GetProperty("host").GetString().ShouldBe("10.0.0.1");
}
// --- OcspPeerRejectEventMsg ---
[Fact]
public void OcspPeerRejectEventMsg_has_correct_type()
{
var evt = new OcspPeerRejectEventMsg
{
Id = "evt-5",
Time = DateTime.UtcNow,
Kind = "client",
Server = new EventServerInfo { Name = "s1", Id = "SRV1" },
Reason = "OCSP revoked",
};
var json = JsonSerializer.Serialize(evt);
var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
root.GetProperty("type").GetString().ShouldBe("io.nats.server.advisory.v1.ocsp_peer_reject");
root.GetProperty("kind").GetString().ShouldBe("client");
root.GetProperty("reason").GetString().ShouldBe("OCSP revoked");
}
// --- ShutdownEventMsg ---
[Fact]
public void ShutdownEventMsg_serializes_reason()
{
var evt = new ShutdownEventMsg
{
Server = new EventServerInfo { Name = "s1", Id = "SRV1" },
Reason = "Server Shutdown",
};
var json = JsonSerializer.Serialize(evt);
var doc = JsonDocument.Parse(json);
doc.RootElement.GetProperty("reason").GetString().ShouldBe("Server Shutdown");
}
// --- AccNumConnsReq ---
[Fact]
public void AccNumConnsReq_serializes_account()
{
var req = new AccNumConnsReq
{
Server = new EventServerInfo { Name = "s1", Id = "SRV1" },
Account = "myAccount",
};
var json = JsonSerializer.Serialize(req);
var doc = JsonDocument.Parse(json);
doc.RootElement.GetProperty("acc").GetString().ShouldBe("myAccount");
}
// --- Round-trip deserialization ---
[Fact]
public void ConnectEventMsg_roundtrips_through_json()
{
var original = new ConnectEventMsg
{
Id = "rt-1",
Time = new DateTime(2025, 6, 15, 12, 0, 0, DateTimeKind.Utc),
Server = new EventServerInfo { Name = "srv", Id = "SRV1", Version = "2.10.0", Seq = 5 },
Client = new EventClientInfo
{
Id = 42,
Host = "10.0.0.1",
Account = "$G",
Name = "test",
Lang = "dotnet",
Version = "1.0.0",
RttNanos = 1_000_000,
Kind = "Client",
},
};
var json = JsonSerializer.Serialize(original);
var deserialized = JsonSerializer.Deserialize<ConnectEventMsg>(json);
deserialized.ShouldNotBeNull();
deserialized.Type.ShouldBe(ConnectEventMsg.EventType);
deserialized.Id.ShouldBe("rt-1");
deserialized.Server.Name.ShouldBe("srv");
deserialized.Server.Seq.ShouldBe(5UL);
deserialized.Client.Id.ShouldBe(42UL);
deserialized.Client.Kind.ShouldBe("Client");
deserialized.Client.RttNanos.ShouldBe(1_000_000);
}
[Fact]
public void ServerStatsMsg_roundtrips_through_json()
{
var original = new ServerStatsMsg
{
Server = new EventServerInfo { Name = "srv", Id = "SRV1" },
Stats = new ServerStatsData
{
Connections = 10,
Sent = new DataStats { Msgs = 100, Bytes = 5000 },
Received = new DataStats { Msgs = 80, Bytes = 4000 },
InMsgs = 80,
OutMsgs = 100,
},
};
var json = JsonSerializer.Serialize(original);
var deserialized = JsonSerializer.Deserialize<ServerStatsMsg>(json);
deserialized.ShouldNotBeNull();
deserialized.Stats.Connections.ShouldBe(10);
deserialized.Stats.Sent.Msgs.ShouldBe(100);
deserialized.Stats.Received.Bytes.ShouldBe(4000);
}
}

View File

@@ -0,0 +1,46 @@
using System.Text.Json;
using NATS.Server.Events;
namespace NATS.Server.Monitoring.Tests.Events;
public class EventServerInfoCapabilityParityBatch1Tests
{
[Fact]
public void ServerCapability_flags_match_expected_values()
{
((ulong)ServerCapability.JetStreamEnabled).ShouldBe(1UL << 0);
((ulong)ServerCapability.BinaryStreamSnapshot).ShouldBe(1UL << 1);
((ulong)ServerCapability.AccountNRG).ShouldBe(1UL << 2);
}
[Fact]
public void EventServerInfo_capability_methods_set_and_read_flags()
{
var info = new EventServerInfo();
info.SetJetStreamEnabled();
info.SetBinaryStreamSnapshot();
info.SetAccountNRG();
info.JetStream.ShouldBeTrue();
info.JetStreamEnabled().ShouldBeTrue();
info.BinaryStreamSnapshot().ShouldBeTrue();
info.AccountNRG().ShouldBeTrue();
}
[Fact]
public void ServerID_serializes_with_name_host_id_fields()
{
var payload = new ServerID
{
Name = "srv-a",
Host = "127.0.0.1",
Id = "N1",
};
var json = JsonSerializer.Serialize(payload);
json.ShouldContain("\"name\":\"srv-a\"");
json.ShouldContain("\"host\":\"127.0.0.1\"");
json.ShouldContain("\"id\":\"N1\"");
}
}

View File

@@ -0,0 +1,308 @@
using System.Text.Json;
using NATS.Server.Events;
namespace NATS.Server.Monitoring.Tests.Events;
/// <summary>
/// Tests that EventBuilder produces fully-populated system event messages
/// and that all field contracts are met.
/// Go reference: events.go sendConnectEventMsg, sendDisconnectEventMsg,
/// sendAccountNumConns, sendStatsz — Gap 10.6.
/// </summary>
public class FullEventPayloadTests
{
// ========================================================================
// BuildConnectEvent
// ========================================================================
[Fact]
public void BuildConnectEvent_AllFieldsPopulated()
{
// Go reference: events.go sendConnectEventMsg — all client fields must be set.
var evt = EventBuilder.BuildConnectEvent(
serverId: "SRV-001",
serverName: "test-server",
cluster: "east-cluster",
clientId: 42,
host: "10.0.0.1",
account: "$G",
user: "alice",
name: "my-client",
lang: "csharp",
version: "1.0.0");
evt.ShouldNotBeNull();
evt.Id.ShouldNotBeNullOrEmpty();
evt.Time.ShouldBeGreaterThan(DateTime.MinValue);
evt.Server.Id.ShouldBe("SRV-001");
evt.Server.Name.ShouldBe("test-server");
evt.Server.Cluster.ShouldBe("east-cluster");
evt.Client.Id.ShouldBe(42UL);
evt.Client.Host.ShouldBe("10.0.0.1");
evt.Client.Account.ShouldBe("$G");
evt.Client.User.ShouldBe("alice");
evt.Client.Name.ShouldBe("my-client");
evt.Client.Lang.ShouldBe("csharp");
evt.Client.Version.ShouldBe("1.0.0");
evt.Client.Start.ShouldBeGreaterThan(DateTime.MinValue);
}
[Fact]
public void BuildConnectEvent_HasCorrectEventType()
{
// Go reference: events.go — connect advisory type constant.
var evt = EventBuilder.BuildConnectEvent(
serverId: "SRV-001",
serverName: "test-server",
cluster: null,
clientId: 1,
host: "127.0.0.1",
account: null,
user: null,
name: null,
lang: null,
version: null);
evt.Type.ShouldBe(ConnectEventMsg.EventType);
evt.Type.ShouldBe("io.nats.server.advisory.v1.client_connect");
}
// ========================================================================
// BuildDisconnectEvent
// ========================================================================
[Fact]
public void BuildDisconnectEvent_AllFieldsPopulated()
{
// Go reference: events.go sendDisconnectEventMsg — sent, received, and reason required.
var sent = new DataStats { Msgs = 100, Bytes = 2_048 };
var received = new DataStats { Msgs = 50, Bytes = 1_024 };
var evt = EventBuilder.BuildDisconnectEvent(
serverId: "SRV-002",
serverName: "test-server",
cluster: "west-cluster",
clientId: 99,
host: "192.168.1.5",
account: "MYACC",
user: "bob",
reason: "Client Closed",
sent: sent,
received: received);
evt.ShouldNotBeNull();
evt.Id.ShouldNotBeNullOrEmpty();
evt.Time.ShouldBeGreaterThan(DateTime.MinValue);
evt.Server.Id.ShouldBe("SRV-002");
evt.Server.Name.ShouldBe("test-server");
evt.Server.Cluster.ShouldBe("west-cluster");
evt.Client.Id.ShouldBe(99UL);
evt.Client.Host.ShouldBe("192.168.1.5");
evt.Client.Account.ShouldBe("MYACC");
evt.Client.User.ShouldBe("bob");
evt.Client.Stop.ShouldBeGreaterThan(DateTime.MinValue);
evt.Reason.ShouldBe("Client Closed");
evt.Sent.Msgs.ShouldBe(100);
evt.Sent.Bytes.ShouldBe(2_048);
evt.Received.Msgs.ShouldBe(50);
evt.Received.Bytes.ShouldBe(1_024);
}
[Fact]
public void BuildDisconnectEvent_HasCorrectEventType()
{
// Go reference: events.go — disconnect advisory type constant.
var evt = EventBuilder.BuildDisconnectEvent(
serverId: "SRV-002",
serverName: "test-server",
cluster: null,
clientId: 1,
host: "127.0.0.1",
account: null,
user: null,
reason: "Server Closed",
sent: new DataStats(),
received: new DataStats());
evt.Type.ShouldBe(DisconnectEventMsg.EventType);
evt.Type.ShouldBe("io.nats.server.advisory.v1.client_disconnect");
}
// ========================================================================
// BuildAccountConnsEvent
// ========================================================================
[Fact]
public void BuildAccountConnsEvent_AllFieldsPopulated()
{
// Go reference: events.go sendAccountNumConns — all AccountStat fields must transfer.
var sent = new DataStats { Msgs = 500, Bytes = 10_000 };
var received = new DataStats { Msgs = 400, Bytes = 8_000 };
var evt = EventBuilder.BuildAccountConnsEvent(
serverId: "SRV-003",
serverName: "test-server",
accountName: "ACCT-A",
connections: 7,
leafNodes: 2,
totalConnections: 150,
numSubscriptions: 33,
sent: sent,
received: received,
slowConsumers: 3);
evt.ShouldNotBeNull();
evt.Id.ShouldNotBeNullOrEmpty();
evt.Time.ShouldBeGreaterThan(DateTime.MinValue);
evt.Server.Id.ShouldBe("SRV-003");
evt.Server.Name.ShouldBe("test-server");
evt.AccountName.ShouldBe("ACCT-A");
evt.Connections.ShouldBe(7);
evt.LeafNodes.ShouldBe(2);
evt.TotalConnections.ShouldBe(150);
evt.NumSubscriptions.ShouldBe(33u);
evt.Sent.Msgs.ShouldBe(500);
evt.Received.Bytes.ShouldBe(8_000);
evt.SlowConsumers.ShouldBe(3);
evt.Type.ShouldBe(AccountNumConns.EventType);
}
// ========================================================================
// BuildServerStats
// ========================================================================
[Fact]
public void BuildServerStats_AllFieldsPopulated()
{
// Go reference: events.go sendStatsz — all stat fields must be set.
var sent = new DataStats { Msgs = 1_000, Bytes = 50_000 };
var received = new DataStats { Msgs = 800, Bytes = 40_000 };
var msg = EventBuilder.BuildServerStats(
serverId: "SRV-004",
serverName: "stats-server",
mem: 134_217_728,
cores: 8,
cpu: 15.5,
connections: 12,
totalConnections: 600,
activeAccounts: 4,
subscriptions: 55,
sent: sent,
received: received);
msg.ShouldNotBeNull();
msg.Server.Id.ShouldBe("SRV-004");
msg.Server.Name.ShouldBe("stats-server");
msg.Stats.Mem.ShouldBe(134_217_728);
msg.Stats.Cores.ShouldBe(8);
msg.Stats.Cpu.ShouldBe(15.5);
msg.Stats.Connections.ShouldBe(12);
msg.Stats.TotalConnections.ShouldBe(600);
msg.Stats.ActiveAccounts.ShouldBe(4);
msg.Stats.Subscriptions.ShouldBe(55);
msg.Stats.Sent.Msgs.ShouldBe(1_000);
msg.Stats.Received.Bytes.ShouldBe(40_000);
// Flat counters mirror the structured stats
msg.Stats.OutMsgs.ShouldBe(1_000);
msg.Stats.InMsgs.ShouldBe(800);
msg.Stats.OutBytes.ShouldBe(50_000);
msg.Stats.InBytes.ShouldBe(40_000);
msg.Stats.Start.ShouldBeGreaterThan(DateTime.MinValue);
}
// ========================================================================
// GenerateEventId
// ========================================================================
[Fact]
public void GenerateEventId_UniquePerCall()
{
// Go reference: events.go uses nuid.Next() — every call produces a distinct ID.
var ids = Enumerable.Range(0, 20).Select(_ => EventBuilder.GenerateEventId()).ToList();
ids.Distinct().Count().ShouldBe(20);
foreach (var id in ids)
{
id.ShouldNotBeNullOrEmpty();
id.Length.ShouldBe(32); // Guid.ToString("N") is always 32 hex chars
}
}
// ========================================================================
// GetTimestamp
// ========================================================================
[Fact]
public void GetTimestamp_ReturnsIso8601()
{
// Go reference: events use RFC3339Nano timestamps; .NET "O" format is ISO 8601.
var ts = EventBuilder.GetTimestamp();
ts.ShouldNotBeNullOrEmpty();
// "O" format: 2025-02-25T12:34:56.7890000Z — parseable as UTC DateTimeOffset
var parsed = DateTimeOffset.Parse(ts);
parsed.Year.ShouldBeGreaterThanOrEqualTo(2024);
}
// ========================================================================
// DataStats default values
// ========================================================================
[Fact]
public void DataStats_DefaultZeroValues()
{
// Go reference: zero-value DataStats is valid and all fields default to 0.
var ds = new DataStats();
ds.Msgs.ShouldBe(0L);
ds.Bytes.ShouldBe(0L);
ds.Gateways.ShouldBeNull();
ds.Routes.ShouldBeNull();
ds.Leafs.ShouldBeNull();
}
// ========================================================================
// JSON roundtrip
// ========================================================================
[Fact]
public void ConnectEventMsg_SerializesToJson()
{
// Go reference: events.go — connect event serializes to JSON and round-trips.
var original = EventBuilder.BuildConnectEvent(
serverId: "SRV-RT",
serverName: "roundtrip-server",
cluster: "rt-cluster",
clientId: 77,
host: "10.1.2.3",
account: "RTACC",
user: "rtuser",
name: "rt-client",
lang: "dotnet",
version: "2.0.0");
var json = JsonSerializer.Serialize(original);
json.ShouldNotBeNullOrEmpty();
json.ShouldContain("\"type\":");
json.ShouldContain(ConnectEventMsg.EventType);
json.ShouldContain("\"server\":");
json.ShouldContain("\"client\":");
json.ShouldContain("\"SRV-RT\"");
var deserialized = JsonSerializer.Deserialize<ConnectEventMsg>(json);
deserialized.ShouldNotBeNull();
deserialized!.Type.ShouldBe(ConnectEventMsg.EventType);
deserialized.Id.ShouldBe(original.Id);
deserialized.Server.Id.ShouldBe("SRV-RT");
deserialized.Server.Name.ShouldBe("roundtrip-server");
deserialized.Server.Cluster.ShouldBe("rt-cluster");
deserialized.Client.Id.ShouldBe(77UL);
deserialized.Client.Account.ShouldBe("RTACC");
deserialized.Client.User.ShouldBe("rtuser");
deserialized.Client.Lang.ShouldBe("dotnet");
deserialized.Client.Version.ShouldBe("2.0.0");
}
}

View File

@@ -0,0 +1,266 @@
// Port of Go server/ocsp_test.go — OCSP peer reject and chain validation advisory tests.
// Go reference: golang/nats-server/server/ocsp.go — postOCSPPeerRejectEvent,
// OCSP chain validation advisory publishing (Gap 10.10).
//
// Tests cover: OcspPeerRejectEventMsg, OcspChainValidationEvent, OcspEventBuilder,
// OcspStatus enum, subject constants, and JSON serialisation round-trip.
using System.Text.Json;
using NATS.Server.Events;
namespace NATS.Server.Monitoring.Tests.Events;
/// <summary>
/// Tests for <see cref="OcspPeerRejectEventMsg"/>, <see cref="OcspChainValidationEvent"/>,
/// <see cref="OcspEventBuilder"/>, and the <see cref="OcspStatus"/> enum.
/// Go reference: ocsp.go — OCSP peer verification advisory events (Gap 10.10).
/// </summary>
public class OcspEventTests
{
// ========================================================================
// OcspPeerRejectEventMsg
// Go reference: ocsp.go postOCSPPeerRejectEvent
// ========================================================================
/// <summary>
/// EventType constant must match the Go advisory type string.
/// Go reference: ocsp.go — "io.nats.server.advisory.v1.ocsp_peer_reject".
/// </summary>
[Fact]
public void OcspPeerRejectEventMsg_HasCorrectEventType()
{
// Go reference: ocsp.go — advisory type constant.
OcspPeerRejectEventMsg.EventType.ShouldBe("io.nats.server.advisory.v1.ocsp_peer_reject");
var ev = new OcspPeerRejectEventMsg();
ev.Type.ShouldBe(OcspPeerRejectEventMsg.EventType);
}
/// <summary>
/// Each OcspPeerRejectEventMsg gets its own unique non-empty Id.
/// Go reference: ocsp.go — nuid.Next() generates a unique event ID per advisory.
/// </summary>
[Fact]
public void OcspPeerRejectEventMsg_GeneratesUniqueId()
{
// Go reference: ocsp.go — each advisory is assigned a unique ID.
var ev1 = new OcspPeerRejectEventMsg { Id = EventBuilder.GenerateEventId() };
var ev2 = new OcspPeerRejectEventMsg { Id = EventBuilder.GenerateEventId() };
ev1.Id.ShouldNotBeNullOrEmpty();
ev2.Id.ShouldNotBeNullOrEmpty();
ev1.Id.ShouldNotBe(ev2.Id);
}
// ========================================================================
// OcspChainValidationEvent
// Go reference: ocsp.go — OCSP chain validation advisory
// ========================================================================
/// <summary>
/// EventType constant must match the advisory type string.
/// Go reference: ocsp.go — "io.nats.server.advisory.v1.ocsp_chain_validation".
/// </summary>
[Fact]
public void OcspChainValidationEvent_HasCorrectEventType()
{
// Go reference: ocsp.go — chain validation advisory type constant.
OcspChainValidationEvent.EventType.ShouldBe("io.nats.server.advisory.v1.ocsp_chain_validation");
var ev = new OcspChainValidationEvent();
ev.Type.ShouldBe(OcspChainValidationEvent.EventType);
}
/// <summary>
/// All optional fields on OcspChainValidationEvent can be assigned.
/// Go reference: ocsp.go — chain validation advisory carries cert metadata.
/// </summary>
[Fact]
public void OcspChainValidationEvent_AllFieldsSettable()
{
// Go reference: ocsp.go — OCSP advisory fields: subject, issuer, serial, status.
var server = new EventServerInfo { Id = "srv1", Name = "test-server" };
var checkedAt = new DateTime(2026, 2, 25, 12, 0, 0, DateTimeKind.Utc);
var ev = new OcspChainValidationEvent
{
Server = server,
CertSubject = "CN=leaf.example.com",
CertIssuer = "CN=Intermediate CA",
SerialNumber = "0123456789abcdef",
OcspStatus = "good",
CheckedAt = checkedAt,
Error = null,
};
ev.Server.ShouldBeSameAs(server);
ev.CertSubject.ShouldBe("CN=leaf.example.com");
ev.CertIssuer.ShouldBe("CN=Intermediate CA");
ev.SerialNumber.ShouldBe("0123456789abcdef");
ev.OcspStatus.ShouldBe("good");
ev.CheckedAt.ShouldBe(checkedAt);
ev.Error.ShouldBeNull();
}
// ========================================================================
// OcspEventBuilder
// Go reference: ocsp.go — postOCSPPeerRejectEvent, chain validation advisory
// ========================================================================
/// <summary>
/// BuildPeerReject populates all required fields of OcspPeerRejectEventMsg.
/// Go reference: ocsp.go postOCSPPeerRejectEvent — kind and reason always set.
/// </summary>
[Fact]
public void BuildPeerReject_PopulatesAllFields()
{
// Go reference: ocsp.go — peer reject advisory carries kind + reason.
var ev = OcspEventBuilder.BuildPeerReject(
serverId: "srv-abc",
serverName: "my-server",
kind: "client",
reason: "certificate revoked");
ev.Id.ShouldNotBeNullOrEmpty();
ev.Type.ShouldBe(OcspPeerRejectEventMsg.EventType);
ev.Server.ShouldNotBeNull();
ev.Server.Id.ShouldBe("srv-abc");
ev.Server.Name.ShouldBe("my-server");
ev.Kind.ShouldBe("client");
ev.Reason.ShouldBe("certificate revoked");
ev.Time.ShouldNotBe(default);
}
/// <summary>
/// BuildChainValidation populates all fields of OcspChainValidationEvent.
/// Go reference: ocsp.go — chain validation advisory carries full cert metadata.
/// </summary>
[Fact]
public void BuildChainValidation_PopulatesAllFields()
{
// Go reference: ocsp.go — chain validation advisory fields.
var before = DateTime.UtcNow;
var ev = OcspEventBuilder.BuildChainValidation(
serverId: "srv-xyz",
serverName: "nats-1",
certSubject: "CN=client.example.com",
certIssuer: "CN=Root CA",
serialNumber: "deadbeef",
ocspStatus: "good",
error: null);
var after = DateTime.UtcNow;
ev.Id.ShouldNotBeNullOrEmpty();
ev.Type.ShouldBe(OcspChainValidationEvent.EventType);
ev.Server.ShouldNotBeNull();
ev.Server!.Id.ShouldBe("srv-xyz");
ev.Server.Name.ShouldBe("nats-1");
ev.CertSubject.ShouldBe("CN=client.example.com");
ev.CertIssuer.ShouldBe("CN=Root CA");
ev.SerialNumber.ShouldBe("deadbeef");
ev.OcspStatus.ShouldBe("good");
ev.CheckedAt.ShouldNotBeNull();
ev.CheckedAt!.Value.ShouldBeInRange(before, after);
ev.Error.ShouldBeNull();
}
// ========================================================================
// OcspStatus enum — ParseStatus
// Go reference: ocsp.go — ocspStatusGood, ocspStatusRevoked constants
// ========================================================================
/// <summary>
/// ParseStatus("good") returns OcspStatus.Good.
/// Go reference: ocsp.go — ocspStatusGood = "good".
/// </summary>
[Fact]
public void ParseStatus_Good()
{
// Go reference: ocsp.go — ocspStatusGood.
OcspEventBuilder.ParseStatus("good").ShouldBe(OcspStatus.Good);
OcspEventBuilder.ParseStatus("Good").ShouldBe(OcspStatus.Good);
OcspEventBuilder.ParseStatus("GOOD").ShouldBe(OcspStatus.Good);
}
/// <summary>
/// ParseStatus("revoked") returns OcspStatus.Revoked.
/// Go reference: ocsp.go — ocspStatusRevoked = "revoked".
/// </summary>
[Fact]
public void ParseStatus_Revoked()
{
// Go reference: ocsp.go — ocspStatusRevoked.
OcspEventBuilder.ParseStatus("revoked").ShouldBe(OcspStatus.Revoked);
OcspEventBuilder.ParseStatus("Revoked").ShouldBe(OcspStatus.Revoked);
OcspEventBuilder.ParseStatus("REVOKED").ShouldBe(OcspStatus.Revoked);
}
/// <summary>
/// ParseStatus with null or unrecognised string returns OcspStatus.Unknown.
/// Go reference: ocsp.go — unknown/unrecognised OCSP status treated as unknown.
/// </summary>
[Fact]
public void ParseStatus_Unknown()
{
// Go reference: ocsp.go — default case maps to unknown status.
OcspEventBuilder.ParseStatus(null).ShouldBe(OcspStatus.Unknown);
OcspEventBuilder.ParseStatus("unknown").ShouldBe(OcspStatus.Unknown);
OcspEventBuilder.ParseStatus("").ShouldBe(OcspStatus.Unknown);
OcspEventBuilder.ParseStatus("invalid-status").ShouldBe(OcspStatus.Unknown);
}
// ========================================================================
// JSON serialisation round-trip
// Go reference: ocsp.go — advisories are published as JSON payloads
// ========================================================================
/// <summary>
/// OcspPeerRejectEventMsg and OcspChainValidationEvent round-trip through JSON
/// preserving all set fields.
/// Go reference: ocsp.go — OCSP advisories serialised as JSON before publishing.
/// </summary>
[Fact]
public void OcspEvents_SerializeToJson()
{
// Go reference: ocsp.go — advisories are JSON-encoded for publishing.
var peerReject = OcspEventBuilder.BuildPeerReject(
serverId: "srv-1",
serverName: "node-a",
kind: "route",
reason: "OCSP status revoked");
var chainValidation = OcspEventBuilder.BuildChainValidation(
serverId: "srv-2",
serverName: "node-b",
certSubject: "CN=server.nats.io",
certIssuer: "CN=NATS CA",
serialNumber: "cafebabe",
ocspStatus: "revoked",
error: "certificate has been revoked");
// Serialise OcspPeerRejectEventMsg
var rejectJson = JsonSerializer.Serialize(peerReject);
rejectJson.ShouldContain("io.nats.server.advisory.v1.ocsp_peer_reject");
rejectJson.ShouldContain("route");
rejectJson.ShouldContain("OCSP status revoked");
var rejectDeserialized = JsonSerializer.Deserialize<OcspPeerRejectEventMsg>(rejectJson);
rejectDeserialized.ShouldNotBeNull();
rejectDeserialized!.Type.ShouldBe(OcspPeerRejectEventMsg.EventType);
rejectDeserialized.Kind.ShouldBe("route");
rejectDeserialized.Reason.ShouldBe("OCSP status revoked");
rejectDeserialized.Server.Id.ShouldBe("srv-1");
rejectDeserialized.Server.Name.ShouldBe("node-a");
// Serialise OcspChainValidationEvent
var chainJson = JsonSerializer.Serialize(chainValidation);
chainJson.ShouldContain("io.nats.server.advisory.v1.ocsp_chain_validation");
chainJson.ShouldContain("CN=server.nats.io");
chainJson.ShouldContain("revoked");
var chainDeserialized = JsonSerializer.Deserialize<OcspChainValidationEvent>(chainJson);
chainDeserialized.ShouldNotBeNull();
chainDeserialized!.Type.ShouldBe(OcspChainValidationEvent.EventType);
chainDeserialized.CertSubject.ShouldBe("CN=server.nats.io");
chainDeserialized.CertIssuer.ShouldBe("CN=NATS CA");
chainDeserialized.SerialNumber.ShouldBe("cafebabe");
chainDeserialized.OcspStatus.ShouldBe("revoked");
chainDeserialized.Error.ShouldBe("certificate has been revoked");
}
}

View File

@@ -0,0 +1,200 @@
using System.Text.Json;
using NATS.Server.Events;
namespace NATS.Server.Monitoring.Tests.Events;
/// <summary>
/// Tests for remote server and leaf node event DTOs and subject constants.
/// Go reference: events.go — remote server lifecycle, leaf node advisory subjects.
/// Gap 10.8: RemoteServerShutdown, RemoteServerUpdate, LeafNodeConnected events.
/// </summary>
public class RemoteServerEventTests
{
// --- RemoteServerShutdownEvent ---
[Fact]
public void RemoteServerShutdownEvent_HasCorrectEventType()
{
RemoteServerShutdownEvent.EventType.ShouldBe("io.nats.server.advisory.v1.remote_shutdown");
var ev = new RemoteServerShutdownEvent();
ev.Type.ShouldBe(RemoteServerShutdownEvent.EventType);
}
[Fact]
public void RemoteServerShutdownEvent_GeneratesUniqueId()
{
var ev1 = new RemoteServerShutdownEvent();
var ev2 = new RemoteServerShutdownEvent();
ev1.Id.ShouldNotBeNullOrEmpty();
ev2.Id.ShouldNotBeNullOrEmpty();
ev1.Id.ShouldNotBe(ev2.Id);
}
// --- RemoteServerUpdateEvent ---
[Fact]
public void RemoteServerUpdateEvent_HasCorrectEventType()
{
RemoteServerUpdateEvent.EventType.ShouldBe("io.nats.server.advisory.v1.remote_update");
var ev = new RemoteServerUpdateEvent();
ev.Type.ShouldBe(RemoteServerUpdateEvent.EventType);
}
[Fact]
public void RemoteServerUpdateEvent_AllFieldsSettable()
{
var server = new EventServerInfo { Id = "srv1", Name = "my-server" };
var ev = new RemoteServerUpdateEvent
{
Server = server,
RemoteServerId = "remote-id-123",
RemoteServerName = "remote-server",
UpdateType = "routes_changed",
};
ev.Server.ShouldBeSameAs(server);
ev.RemoteServerId.ShouldBe("remote-id-123");
ev.RemoteServerName.ShouldBe("remote-server");
ev.UpdateType.ShouldBe("routes_changed");
}
// --- LeafNodeConnectEvent ---
[Fact]
public void LeafNodeConnectEvent_HasCorrectEventType()
{
LeafNodeConnectEvent.EventType.ShouldBe("io.nats.server.advisory.v1.leafnode_connect");
var ev = new LeafNodeConnectEvent();
ev.Type.ShouldBe(LeafNodeConnectEvent.EventType);
}
[Fact]
public void LeafNodeConnectEvent_AllFieldsSettable()
{
var server = new EventServerInfo { Id = "srv1", Name = "hub" };
var ev = new LeafNodeConnectEvent
{
Server = server,
LeafNodeId = "leaf-id-abc",
LeafNodeName = "leaf-node-1",
RemoteUrl = "nats://10.0.0.1:7422",
Account = "ACC",
};
ev.Server.ShouldBeSameAs(server);
ev.LeafNodeId.ShouldBe("leaf-id-abc");
ev.LeafNodeName.ShouldBe("leaf-node-1");
ev.RemoteUrl.ShouldBe("nats://10.0.0.1:7422");
ev.Account.ShouldBe("ACC");
}
// --- LeafNodeDisconnectEvent ---
[Fact]
public void LeafNodeDisconnectEvent_HasCorrectEventType()
{
LeafNodeDisconnectEvent.EventType.ShouldBe("io.nats.server.advisory.v1.leafnode_disconnect");
var ev = new LeafNodeDisconnectEvent();
ev.Type.ShouldBe(LeafNodeDisconnectEvent.EventType);
}
[Fact]
public void LeafNodeDisconnectEvent_AllFieldsSettable()
{
var server = new EventServerInfo { Id = "srv1", Name = "hub" };
var ev = new LeafNodeDisconnectEvent
{
Server = server,
LeafNodeId = "leaf-id-xyz",
Reason = "connection closed",
};
ev.Server.ShouldBeSameAs(server);
ev.LeafNodeId.ShouldBe("leaf-id-xyz");
ev.Reason.ShouldBe("connection closed");
}
// --- EventSubjects ---
[Fact]
public void EventSubjects_RemoteShutdown_HasPlaceholder()
{
EventSubjects.RemoteServerShutdown.ShouldContain("{0}");
EventSubjects.RemoteServerUpdate.ShouldContain("{0}");
EventSubjects.LeafNodeConnected.ShouldContain("{0}");
EventSubjects.LeafNodeDisconnected.ShouldContain("{0}");
// Verify format strings produce expected subjects when formatted
var serverId = "ABCDEF123456";
string.Format(EventSubjects.RemoteServerShutdown, serverId)
.ShouldBe($"$SYS.SERVER.{serverId}.REMOTE.SHUTDOWN");
string.Format(EventSubjects.LeafNodeConnected, serverId)
.ShouldBe($"$SYS.ACCOUNT.{serverId}.LEAFNODE.CONNECT");
}
// --- JSON serialization ---
[Fact]
public void AllRemoteEvents_SerializeToJson()
{
var server = new EventServerInfo { Id = "srv-id", Name = "test-server" };
var shutdown = new RemoteServerShutdownEvent
{
Server = server,
RemoteServerId = "r1",
RemoteServerName = "remote",
Reason = "graceful",
};
var shutdownJson = JsonSerializer.Serialize(shutdown);
var shutdownDoc = JsonDocument.Parse(shutdownJson).RootElement;
shutdownDoc.GetProperty("type").GetString().ShouldBe(RemoteServerShutdownEvent.EventType);
shutdownDoc.GetProperty("id").GetString().ShouldNotBeNullOrEmpty();
shutdownDoc.GetProperty("remote_server_id").GetString().ShouldBe("r1");
shutdownDoc.GetProperty("reason").GetString().ShouldBe("graceful");
var update = new RemoteServerUpdateEvent
{
Server = server,
RemoteServerId = "r2",
RemoteServerName = "remote2",
UpdateType = "config_updated",
};
var updateJson = JsonSerializer.Serialize(update);
var updateDoc = JsonDocument.Parse(updateJson).RootElement;
updateDoc.GetProperty("type").GetString().ShouldBe(RemoteServerUpdateEvent.EventType);
updateDoc.GetProperty("update_type").GetString().ShouldBe("config_updated");
var connect = new LeafNodeConnectEvent
{
Server = server,
LeafNodeId = "leaf1",
LeafNodeName = "leaf-node",
RemoteUrl = "nats://10.0.0.1:7422",
Account = "ACC",
};
var connectJson = JsonSerializer.Serialize(connect);
var connectDoc = JsonDocument.Parse(connectJson).RootElement;
connectDoc.GetProperty("type").GetString().ShouldBe(LeafNodeConnectEvent.EventType);
connectDoc.GetProperty("leaf_node_id").GetString().ShouldBe("leaf1");
connectDoc.GetProperty("account").GetString().ShouldBe("ACC");
var disconnect = new LeafNodeDisconnectEvent
{
Server = server,
LeafNodeId = "leaf1",
Reason = "timeout",
};
var disconnectJson = JsonSerializer.Serialize(disconnect);
var disconnectDoc = JsonDocument.Parse(disconnectJson).RootElement;
disconnectDoc.GetProperty("type").GetString().ShouldBe(LeafNodeDisconnectEvent.EventType);
disconnectDoc.GetProperty("leaf_node_id").GetString().ShouldBe("leaf1");
disconnectDoc.GetProperty("reason").GetString().ShouldBe("timeout");
// Roundtrip: deserialize back and verify type field survives
var shutdownRt = JsonSerializer.Deserialize<RemoteServerShutdownEvent>(shutdownJson);
shutdownRt.ShouldNotBeNull();
shutdownRt!.Type.ShouldBe(RemoteServerShutdownEvent.EventType);
shutdownRt.RemoteServerId.ShouldBe("r1");
}
}

View File

@@ -0,0 +1,421 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server;
using NATS.Server.Auth;
using NATS.Server.Events;
using NATS.Server.TestUtilities;
namespace NATS.Server.Monitoring.Tests.Events;
/// <summary>
/// Tests for server lifecycle events, stats tracking, advisory messages, and
/// $SYS subject infrastructure.
/// Go reference: events_test.go (51 tests).
/// </summary>
public class ServerEventTests : IAsyncLifetime
{
private readonly NatsServer _server;
private readonly int _port;
private readonly CancellationTokenSource _cts = new();
public ServerEventTests()
{
_port = TestPortAllocator.GetFreePort();
_server = new NatsServer(new NatsOptions { Port = _port }, NullLoggerFactory.Instance);
}
public async Task InitializeAsync()
{
_ = _server.StartAsync(_cts.Token);
await _server.WaitForReadyAsync();
}
public async Task DisposeAsync()
{
await _cts.CancelAsync();
_server.Dispose();
}
private async Task<Socket> ConnectAndHandshakeAsync()
{
var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await sock.ConnectAsync(IPAddress.Loopback, _port);
// Read INFO
var buf = new byte[4096];
await sock.ReceiveAsync(buf, SocketFlags.None);
// Send CONNECT + PING
await sock.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\nPING\r\n"));
// Read PONG (may include -ERR or other lines)
await SocketTestHelper.ReadUntilAsync(sock, "PONG");
return sock;
}
// -----------------------------------------------------------------------
// Server lifecycle events
// -----------------------------------------------------------------------
/// <summary>
/// Server exposes Stats property at startup with all counters at zero.
/// Go reference: events_test.go TestServerEventsStatsZ (line ~100).
/// </summary>
[Fact]
public void Server_stats_initialized_to_zero_at_startup()
{
var stats = _server.Stats;
stats.InMsgs.ShouldBe(0L);
stats.OutMsgs.ShouldBe(0L);
stats.InBytes.ShouldBe(0L);
stats.OutBytes.ShouldBe(0L);
stats.SlowConsumers.ShouldBe(0L);
}
/// <summary>
/// TotalConnections increments each time a new client connects.
/// Go reference: events_test.go TestServerEventsTotalConnections (line ~150).
/// </summary>
[Fact]
public async Task TotalConnections_increments_on_each_new_connection()
{
var before = Interlocked.Read(ref _server.Stats.TotalConnections);
using var c1 = await ConnectAndHandshakeAsync();
using var c2 = await ConnectAndHandshakeAsync();
var after = Interlocked.Read(ref _server.Stats.TotalConnections);
(after - before).ShouldBeGreaterThanOrEqualTo(2L);
}
/// <summary>
/// ClientCount reflects only currently connected clients.
/// Go reference: events_test.go TestServerEventsStatsCID (line ~200).
/// </summary>
[Fact]
public async Task ClientCount_decrements_when_client_disconnects()
{
var sock = await ConnectAndHandshakeAsync();
var countWhileConnected = _server.ClientCount;
countWhileConnected.ShouldBeGreaterThanOrEqualTo(1);
sock.Shutdown(SocketShutdown.Both);
sock.Dispose();
// Allow server time to process the disconnection
await Task.Delay(100);
_server.ClientCount.ShouldBeLessThan(countWhileConnected + 1);
}
/// <summary>
/// Multiple simultaneous connections are tracked independently.
/// Go reference: events_test.go TestServerEventsConcurrentConns (line ~230).
/// </summary>
[Fact]
public async Task Multiple_connections_tracked_independently()
{
var before = Interlocked.Read(ref _server.Stats.TotalConnections);
using var c1 = await ConnectAndHandshakeAsync();
using var c2 = await ConnectAndHandshakeAsync();
using var c3 = await ConnectAndHandshakeAsync();
var after = Interlocked.Read(ref _server.Stats.TotalConnections);
(after - before).ShouldBeGreaterThanOrEqualTo(3L);
}
/// <summary>
/// Stats are accurate after rapid connect/disconnect cycles.
/// Go reference: events_test.go TestServerEventsStatsCounting (line ~260).
/// </summary>
[Fact]
public async Task Stats_accurate_after_rapid_connect_disconnect()
{
var before = Interlocked.Read(ref _server.Stats.TotalConnections);
for (var i = 0; i < 5; i++)
{
using var sock = await ConnectAndHandshakeAsync();
}
var after = Interlocked.Read(ref _server.Stats.TotalConnections);
(after - before).ShouldBeGreaterThanOrEqualTo(5L);
}
// -----------------------------------------------------------------------
// ServerStats counters — message/byte tracking
// -----------------------------------------------------------------------
/// <summary>
/// InMsgs and InBytes increment when clients publish.
/// Go reference: events_test.go TestServerEventsStatsz (line ~100).
/// </summary>
[Fact]
public async Task InMsgs_and_InBytes_increment_on_publish()
{
using var sock = await ConnectAndHandshakeAsync();
var beforeMsgs = Interlocked.Read(ref _server.Stats.InMsgs);
var beforeBytes = Interlocked.Read(ref _server.Stats.InBytes);
var payload = "Hello"u8.ToArray();
var pub = Encoding.ASCII.GetBytes($"PUB test.subject {payload.Length}\r\nHello\r\n");
await sock.SendAsync(pub);
// Flush via PING/PONG
await sock.SendAsync(Encoding.ASCII.GetBytes("PING\r\n"));
await SocketTestHelper.ReadUntilAsync(sock, "PONG");
var afterMsgs = Interlocked.Read(ref _server.Stats.InMsgs);
var afterBytes = Interlocked.Read(ref _server.Stats.InBytes);
(afterMsgs - beforeMsgs).ShouldBeGreaterThanOrEqualTo(1L);
(afterBytes - beforeBytes).ShouldBeGreaterThanOrEqualTo(payload.Length);
}
/// <summary>
/// OutMsgs and OutBytes increment when messages are delivered to subscribers.
/// Go reference: events_test.go TestServerEventsStatsz (line ~100).
/// </summary>
[Fact]
public async Task OutMsgs_and_OutBytes_increment_on_delivery()
{
using var sub = await ConnectAndHandshakeAsync();
using var pub = await ConnectAndHandshakeAsync();
// Subscribe
await sub.SendAsync(Encoding.ASCII.GetBytes("SUB test.out 1\r\nPING\r\n"));
await SocketTestHelper.ReadUntilAsync(sub, "PONG");
var beforeOut = Interlocked.Read(ref _server.Stats.OutMsgs);
var payload = "World"u8.ToArray();
await pub.SendAsync(Encoding.ASCII.GetBytes($"PUB test.out {payload.Length}\r\nWorld\r\n"));
await pub.SendAsync(Encoding.ASCII.GetBytes("PING\r\n"));
await SocketTestHelper.ReadUntilAsync(pub, "PONG");
// Give delivery loop time to flush
await SocketTestHelper.ReadUntilAsync(sub, "World", timeoutMs: 2000);
var afterOut = Interlocked.Read(ref _server.Stats.OutMsgs);
(afterOut - beforeOut).ShouldBeGreaterThanOrEqualTo(1L);
}
// -----------------------------------------------------------------------
// Account stats events
// -----------------------------------------------------------------------
/// <summary>
/// Account.InMsgs and InBytes track messages received by clients in that account.
/// Go reference: events_test.go TestServerEventsStatsz (line ~100),
/// TestAccountStats (line ~400).
/// </summary>
[Fact]
public void Account_InMsgs_and_InBytes_increment_correctly()
{
// Account.IncrementInbound is the mechanism tracked server-side
var account = new Account("test-account");
account.IncrementInbound(3, 300);
account.InMsgs.ShouldBe(3L);
account.InBytes.ShouldBe(300L);
}
/// <summary>
/// Account.OutMsgs and OutBytes track messages delivered to clients in that account.
/// Go reference: events_test.go TestAccountStats (line ~400).
/// </summary>
[Fact]
public void Account_OutMsgs_and_OutBytes_increment_correctly()
{
var account = new Account("test-account");
account.IncrementOutbound(2, 200);
account.OutMsgs.ShouldBe(2L);
account.OutBytes.ShouldBe(200L);
}
/// <summary>
/// Per-account stats are isolated — changes to one account do not affect another.
/// Go reference: events_test.go TestAccountStats, TestServerEventsAccountIsolation (line ~420).
/// </summary>
[Fact]
public void Account_stats_are_isolated_between_accounts()
{
var a1 = new Account("account-one");
var a2 = new Account("account-two");
a1.IncrementInbound(10, 1000);
a2.IncrementInbound(5, 500);
a1.InMsgs.ShouldBe(10L);
a2.InMsgs.ShouldBe(5L);
a1.InBytes.ShouldBe(1000L);
a2.InBytes.ShouldBe(500L);
}
/// <summary>
/// Account stats start at zero and are independent of each other.
/// Go reference: events_test.go TestAccountStats (line ~400).
/// </summary>
[Fact]
public void Account_stats_start_at_zero()
{
var account = new Account("fresh");
account.InMsgs.ShouldBe(0L);
account.OutMsgs.ShouldBe(0L);
account.InBytes.ShouldBe(0L);
account.OutBytes.ShouldBe(0L);
}
// -----------------------------------------------------------------------
// Advisory messages — slow consumers, stale connections
// -----------------------------------------------------------------------
/// <summary>
/// ServerStats contains SlowConsumers counter for aggregate slow consumer tracking.
/// Go reference: events_test.go TestServerEventsSlowConsumer (line ~500).
/// </summary>
[Fact]
public void Stats_has_SlowConsumers_field()
{
var stats = _server.Stats;
// Field exists and starts at zero
Interlocked.Read(ref stats.SlowConsumers).ShouldBe(0L);
}
/// <summary>
/// ServerStats differentiates slow consumers by connection type.
/// Go reference: events_test.go TestServerEventsSlowConsumer (line ~500).
/// </summary>
[Fact]
public void Stats_has_per_type_SlowConsumer_fields()
{
var stats = _server.Stats;
// All per-type slow-consumer counters exist and start at zero
Interlocked.Read(ref stats.SlowConsumerClients).ShouldBe(0L);
Interlocked.Read(ref stats.SlowConsumerRoutes).ShouldBe(0L);
Interlocked.Read(ref stats.SlowConsumerLeafs).ShouldBe(0L);
Interlocked.Read(ref stats.SlowConsumerGateways).ShouldBe(0L);
}
/// <summary>
/// StaleConnections and per-type stale counters are tracked in ServerStats.
/// Go reference: events_test.go TestServerEventsStaleConnection (line ~550).
/// </summary>
[Fact]
public void Stats_has_StaleConnection_fields()
{
var stats = _server.Stats;
Interlocked.Read(ref stats.StaleConnections).ShouldBe(0L);
Interlocked.Read(ref stats.StaleConnectionClients).ShouldBe(0L);
Interlocked.Read(ref stats.StaleConnectionRoutes).ShouldBe(0L);
Interlocked.Read(ref stats.StaleConnectionLeafs).ShouldBe(0L);
Interlocked.Read(ref stats.StaleConnectionGateways).ShouldBe(0L);
}
// -----------------------------------------------------------------------
// JetStream API stats
// -----------------------------------------------------------------------
/// <summary>
/// JetStreamApiTotal and JetStreamApiErrors counters exist in ServerStats.
/// Go reference: events_test.go TestServerEventsStatsZ JetStream fields (line ~100).
/// </summary>
[Fact]
public void Stats_has_JetStream_api_counters()
{
var stats = _server.Stats;
Interlocked.Read(ref stats.JetStreamApiTotal).ShouldBe(0L);
Interlocked.Read(ref stats.JetStreamApiErrors).ShouldBe(0L);
}
// -----------------------------------------------------------------------
// $SYS subject event infrastructure
// -----------------------------------------------------------------------
/// <summary>
/// EventSubjects constants use $SYS prefix matching Go's event subject patterns.
/// Go reference: events.go:41-97 subject constants.
/// </summary>
[Fact]
public void EventSubjects_have_correct_SYS_prefixes()
{
EventSubjects.ConnectEvent.ShouldStartWith("$SYS.ACCOUNT.");
EventSubjects.DisconnectEvent.ShouldStartWith("$SYS.ACCOUNT.");
EventSubjects.ServerStats.ShouldStartWith("$SYS.SERVER.");
EventSubjects.ServerShutdown.ShouldStartWith("$SYS.SERVER.");
EventSubjects.AuthError.ShouldStartWith("$SYS.SERVER.");
}
/// <summary>
/// EventSubjects include format placeholders for account and server IDs.
/// Go reference: events.go:41-97 format string subject constants.
/// </summary>
[Fact]
public void EventSubjects_format_correctly_with_account_and_server_ids()
{
var connectSubject = string.Format(EventSubjects.ConnectEvent, "MY_ACCOUNT");
connectSubject.ShouldBe("$SYS.ACCOUNT.MY_ACCOUNT.CONNECT");
var statsSubject = string.Format(EventSubjects.ServerStats, "SERVER123");
statsSubject.ShouldBe("$SYS.SERVER.SERVER123.STATSZ");
var shutdownSubject = string.Format(EventSubjects.ServerShutdown, "SERVER123");
shutdownSubject.ShouldBe("$SYS.SERVER.SERVER123.SHUTDOWN");
}
/// <summary>
/// NatsServer exposes a non-null EventSystem after startup.
/// Go reference: events.go initEventTracking — event system initialised during server start.
/// </summary>
[Fact]
public void Server_has_EventSystem_after_start()
{
_server.EventSystem.ShouldNotBeNull();
}
/// <summary>
/// InternalEventSystem.PublishServerStats produces a ServerStatsMsg with server
/// identity and current stats data without throwing.
/// Go reference: events.go sendStatsz (line ~495).
/// </summary>
[Fact]
public void EventSystem_PublishServerStats_does_not_throw()
{
var eventSystem = _server.EventSystem;
eventSystem.ShouldNotBeNull();
// Calling PublishServerStats directly must not throw
var ex = Record.Exception(() => eventSystem!.PublishServerStats());
ex.ShouldBeNull();
}
/// <summary>
/// InternalEventSystem generates unique, monotonically increasing sequence numbers.
/// Go reference: events.go NextSequence / sequence counter (line ~59).
/// </summary>
[Fact]
public void EventSystem_sequence_numbers_are_monotonically_increasing()
{
var es = _server.EventSystem;
es.ShouldNotBeNull();
var s1 = es!.NextSequence();
var s2 = es.NextSequence();
var s3 = es.NextSequence();
s2.ShouldBeGreaterThan(s1);
s3.ShouldBeGreaterThan(s2);
}
/// <summary>
/// BuildEventServerInfo embeds the server name and ID in advisory messages.
/// Go reference: events.go serverInfo() helper (line ~1368 in NatsServer.cs).
/// </summary>
[Fact]
public void BuildEventServerInfo_contains_server_identity()
{
var info = _server.BuildEventServerInfo();
info.ShouldNotBeNull();
info.Id.ShouldNotBeNullOrWhiteSpace();
info.Name.ShouldNotBeNullOrWhiteSpace();
}
}

View File

@@ -0,0 +1,107 @@
using System.Net;
using System.Net.Http.Json;
using System.Net.Sockets;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server.Configuration;
using NATS.Server.Monitoring;
using NATS.Server.TestUtilities;
namespace NATS.Server.Monitoring.Tests;
public class JszMonitorTests
{
[Fact]
public async Task Jsz_reports_live_stream_and_consumer_counts()
{
await using var fixture = await JetStreamMonitoringFixture.StartWithStreamAndConsumerAsync();
var jsz = await fixture.GetJszAsync();
jsz.Streams.ShouldBeGreaterThan(0);
jsz.Consumers.ShouldBeGreaterThan(0);
}
}
internal sealed class JetStreamMonitoringFixture : IAsyncDisposable
{
private readonly NatsServer _server;
private readonly int _monitorPort;
private readonly CancellationTokenSource _cts = new();
private readonly HttpClient _http = new();
private JetStreamMonitoringFixture(NatsServer server, int monitorPort)
{
_server = server;
_monitorPort = monitorPort;
}
public static async Task<JetStreamMonitoringFixture> StartWithStreamAndConsumerAsync()
{
var natsPort = TestPortAllocator.GetFreePort();
var monitorPort = TestPortAllocator.GetFreePort();
var options = new NatsOptions
{
Host = "127.0.0.1",
Port = natsPort,
MonitorHost = "127.0.0.1",
MonitorPort = monitorPort,
JetStream = new JetStreamOptions
{
StoreDir = Path.Combine(Path.GetTempPath(), "natsdotnet-jsz"),
MaxMemoryStore = 1_024 * 1_024,
MaxFileStore = 10 * 1_024 * 1_024,
},
};
var server = new NatsServer(options, NullLoggerFactory.Instance);
var fixture = new JetStreamMonitoringFixture(server, monitorPort);
_ = server.StartAsync(fixture._cts.Token);
await server.WaitForReadyAsync();
await fixture.WaitForHealthAsync();
var router = server.JetStreamApiRouter ?? throw new InvalidOperationException("JetStream API router unavailable.");
_ = router.Route("$JS.API.STREAM.CREATE.ORDERS", Encoding.UTF8.GetBytes("{\"name\":\"ORDERS\",\"subjects\":[\"orders.*\"]}"));
_ = router.Route("$JS.API.CONSUMER.CREATE.ORDERS.DUR", Encoding.UTF8.GetBytes("{\"durable_name\":\"DUR\",\"filter_subject\":\"orders.*\"}"));
return fixture;
}
public async Task<JszResponse> GetJszAsync()
{
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/jsz");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var jsz = await response.Content.ReadFromJsonAsync<JszResponse>();
return jsz ?? throw new InvalidOperationException("Failed to deserialize /jsz.");
}
public async ValueTask DisposeAsync()
{
_http.Dispose();
await _cts.CancelAsync();
_server.Dispose();
}
private async Task WaitForHealthAsync()
{
for (int i = 0; i < 50; i++)
{
try
{
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/healthz");
if (response.IsSuccessStatusCode)
return;
}
catch (HttpRequestException)
{
// server not ready
}
await Task.Delay(50);
}
throw new TimeoutException("Monitoring endpoint did not become healthy.");
}
}

View File

@@ -0,0 +1,100 @@
using System.Net;
using System.Net.Sockets;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server.Configuration;
using NATS.Server.TestUtilities;
namespace NATS.Server.Monitoring.Tests;
public class MonitorClusterEndpointTests
{
[Fact]
public async Task Routez_gatewayz_leafz_accountz_return_non_stub_runtime_data()
{
await using var fx = await MonitorFixture.StartClusterEnabledAsync();
(await fx.GetJsonAsync("/routez")).ShouldContain("routes");
(await fx.GetJsonAsync("/gatewayz")).ShouldContain("gateways");
(await fx.GetJsonAsync("/leafz")).ShouldContain("leafs");
(await fx.GetJsonAsync("/accountz")).ShouldContain("accounts");
}
}
internal sealed class MonitorFixture : IAsyncDisposable
{
private readonly NatsServer _server;
private readonly CancellationTokenSource _cts;
private readonly HttpClient _http;
private readonly int _monitorPort;
private MonitorFixture(NatsServer server, CancellationTokenSource cts, HttpClient http, int monitorPort)
{
_server = server;
_cts = cts;
_http = http;
_monitorPort = monitorPort;
}
public static async Task<MonitorFixture> StartClusterEnabledAsync()
{
var monitorPort = TestPortAllocator.GetFreePort();
var options = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
MonitorPort = monitorPort,
Cluster = new ClusterOptions
{
Host = "127.0.0.1",
Port = 0,
},
Gateway = new GatewayOptions
{
Host = "127.0.0.1",
Port = 0,
Name = "M",
},
LeafNode = new LeafNodeOptions
{
Host = "127.0.0.1",
Port = 0,
},
};
var server = new NatsServer(options, NullLoggerFactory.Instance);
var cts = new CancellationTokenSource();
_ = server.StartAsync(cts.Token);
await server.WaitForReadyAsync();
var http = new HttpClient();
for (var i = 0; i < 50; i++)
{
try
{
var response = await http.GetAsync($"http://127.0.0.1:{monitorPort}/healthz");
if (response.IsSuccessStatusCode)
break;
}
catch
{
}
await Task.Delay(50);
}
return new MonitorFixture(server, cts, http, monitorPort);
}
public Task<string> GetJsonAsync(string path)
{
return _http.GetStringAsync($"http://127.0.0.1:{_monitorPort}{path}");
}
public async ValueTask DisposeAsync()
{
_http.Dispose();
await _cts.CancelAsync();
_server.Dispose();
_cts.Dispose();
}
}

View File

@@ -0,0 +1,51 @@
using System.Text.Json;
using NATS.Server.Monitoring;
namespace NATS.Server.Monitoring.Tests;
public class MonitorModelTests
{
[Fact]
public void Varz_serializes_with_go_field_names()
{
var varz = new Varz
{
Id = "TESTID", Name = "test-server", Version = "0.1.0",
Host = "0.0.0.0", Port = 4222, InMsgs = 100, OutMsgs = 200,
};
var json = JsonSerializer.Serialize(varz);
json.ShouldContain("\"server_id\":");
json.ShouldContain("\"server_name\":");
json.ShouldContain("\"in_msgs\":");
json.ShouldContain("\"out_msgs\":");
json.ShouldNotContain("\"InMsgs\"");
}
[Fact]
public void Connz_serializes_with_go_field_names()
{
var connz = new Connz
{
Id = "TESTID", Now = DateTime.UtcNow, NumConns = 1, Total = 1, Limit = 1024,
Conns = [new ConnInfo { Cid = 1, Ip = "127.0.0.1", Port = 5555,
InMsgs = 10, Uptime = "1s", Idle = "0s",
Start = DateTime.UtcNow, LastActivity = DateTime.UtcNow }],
};
var json = JsonSerializer.Serialize(connz);
json.ShouldContain("\"server_id\":");
json.ShouldContain("\"num_connections\":");
json.ShouldContain("\"in_msgs\":");
json.ShouldContain("\"pending_bytes\":");
}
[Fact]
public void Varz_includes_nested_config_stubs()
{
var varz = new Varz { Id = "X", Name = "X", Version = "X", Host = "X" };
var json = JsonSerializer.Serialize(varz);
json.ShouldContain("\"cluster\":");
json.ShouldContain("\"gateway\":");
json.ShouldContain("\"leaf\":");
json.ShouldContain("\"jetstream\":");
}
}

View File

@@ -0,0 +1,372 @@
using System.Net;
using System.Net.Http.Json;
using System.Net.Security;
using System.Net.Sockets;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server.Monitoring;
using NATS.Server.TestUtilities;
namespace NATS.Server.Monitoring.Tests;
public class MonitorTests : IAsyncLifetime
{
private readonly NatsServer _server;
private readonly int _natsPort;
private readonly int _monitorPort;
private readonly CancellationTokenSource _cts = new();
private readonly HttpClient _http = new();
public MonitorTests()
{
_natsPort = TestPortAllocator.GetFreePort();
_monitorPort = TestPortAllocator.GetFreePort();
_server = new NatsServer(
new NatsOptions { Port = _natsPort, MonitorPort = _monitorPort },
NullLoggerFactory.Instance);
}
public async Task InitializeAsync()
{
_ = _server.StartAsync(_cts.Token);
await _server.WaitForReadyAsync();
// Wait for monitoring HTTP server to be ready
for (int i = 0; i < 50; i++)
{
try
{
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/healthz");
if (response.IsSuccessStatusCode) break;
}
catch (HttpRequestException) { }
await Task.Delay(50);
}
}
public async Task DisposeAsync()
{
_http.Dispose();
await _cts.CancelAsync();
_server.Dispose();
}
[Fact]
public async Task Healthz_returns_ok()
{
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/healthz");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
}
[Fact]
public async Task Varz_returns_server_identity()
{
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/varz");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var varz = await response.Content.ReadFromJsonAsync<Varz>();
varz.ShouldNotBeNull();
varz.Id.ShouldNotBeNullOrEmpty();
varz.Name.ShouldNotBeNullOrEmpty();
varz.Version.ShouldBe("0.1.0");
varz.Host.ShouldBe("0.0.0.0");
varz.Port.ShouldBe(_natsPort);
varz.MaxPayload.ShouldBe(1024 * 1024);
varz.Uptime.ShouldNotBeNullOrEmpty();
varz.Now.ShouldBeGreaterThan(DateTime.MinValue);
varz.Mem.ShouldBeGreaterThan(0);
varz.Cores.ShouldBeGreaterThan(0);
}
[Fact]
public async Task Varz_tracks_connections_and_messages()
{
// Connect a client and send a message
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _natsPort));
var buf = new byte[4096];
_ = await sock.ReceiveAsync(buf, SocketFlags.None); // Read INFO
var cmd = "CONNECT {}\r\nSUB test 1\r\nPUB test 5\r\nhello\r\n"u8.ToArray();
await sock.SendAsync(cmd, SocketFlags.None);
await Task.Delay(200);
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/varz");
var varz = await response.Content.ReadFromJsonAsync<Varz>();
varz.ShouldNotBeNull();
varz.Connections.ShouldBeGreaterThanOrEqualTo(1);
varz.TotalConnections.ShouldBeGreaterThanOrEqualTo(1UL);
varz.InMsgs.ShouldBeGreaterThanOrEqualTo(1L);
varz.InBytes.ShouldBeGreaterThanOrEqualTo(5L);
}
[Fact]
public async Task Connz_returns_connections()
{
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _natsPort));
using var stream = new NetworkStream(sock);
var buf = new byte[4096];
_ = await stream.ReadAsync(buf);
await stream.WriteAsync("CONNECT {\"name\":\"test-client\",\"lang\":\"csharp\",\"version\":\"1.0\"}\r\n"u8.ToArray());
await Task.Delay(200);
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/connz");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var connz = await response.Content.ReadFromJsonAsync<Connz>();
connz.ShouldNotBeNull();
connz.NumConns.ShouldBeGreaterThanOrEqualTo(1);
connz.Conns.Length.ShouldBeGreaterThanOrEqualTo(1);
var conn = connz.Conns.First(c => c.Name == "test-client");
conn.Ip.ShouldNotBeNullOrEmpty();
conn.Port.ShouldBeGreaterThan(0);
conn.Lang.ShouldBe("csharp");
conn.Version.ShouldBe("1.0");
conn.Uptime.ShouldNotBeNullOrEmpty();
}
[Fact]
public async Task Connz_pagination()
{
var sockets = new List<Socket>();
try
{
for (int i = 0; i < 3; i++)
{
var s = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await s.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _natsPort));
var ns = new NetworkStream(s);
var buf = new byte[4096];
_ = await ns.ReadAsync(buf);
await ns.WriteAsync("CONNECT {}\r\n"u8.ToArray());
sockets.Add(s);
}
await Task.Delay(200);
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/connz?limit=2&offset=0");
var connz = await response.Content.ReadFromJsonAsync<Connz>();
connz!.Conns.Length.ShouldBe(2);
connz.Total.ShouldBeGreaterThanOrEqualTo(3);
connz.Limit.ShouldBe(2);
connz.Offset.ShouldBe(0);
}
finally
{
foreach (var s in sockets) s.Dispose();
}
}
[Fact]
public async Task Connz_with_subscriptions()
{
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _natsPort));
using var stream = new NetworkStream(sock);
var buf = new byte[4096];
_ = await stream.ReadAsync(buf);
await stream.WriteAsync("CONNECT {}\r\nSUB foo 1\r\nSUB bar 2\r\n"u8.ToArray());
await Task.Delay(200);
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/connz?subs=true");
var connz = await response.Content.ReadFromJsonAsync<Connz>();
var conn = connz!.Conns.First(c => c.NumSubs >= 2);
conn.Subs.ShouldNotBeNull();
conn.Subs.ShouldContain("foo");
conn.Subs.ShouldContain("bar");
}
[Fact]
public async Task Connz_state_closed_returns_disconnected_clients()
{
// Connect then disconnect a client
var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _natsPort));
using var stream = new NetworkStream(sock);
var buf = new byte[4096];
_ = await stream.ReadAsync(buf);
await stream.WriteAsync("CONNECT {\"name\":\"closing-client\"}\r\n"u8.ToArray());
await Task.Delay(200);
sock.Shutdown(SocketShutdown.Both);
sock.Dispose();
await Task.Delay(500);
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/connz?state=closed");
var connz = await response.Content.ReadFromJsonAsync<Connz>();
connz.ShouldNotBeNull();
connz.Conns.ShouldContain(c => c.Name == "closing-client");
var closed = connz.Conns.First(c => c.Name == "closing-client");
closed.Stop.ShouldNotBeNull();
closed.Reason.ShouldNotBeNullOrEmpty();
}
[Fact]
public async Task Connz_filters_by_mqtt_client_for_open_connections()
{
// Connect a regular NATS client (no MQTT ID)
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _natsPort));
using var stream = new NetworkStream(sock);
var buf = new byte[4096];
_ = await stream.ReadAsync(buf);
await stream.WriteAsync("CONNECT {}\r\n"u8.ToArray());
await Task.Delay(200);
// Query for an MQTT client ID that no connection has
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/connz?mqtt_client=some-id");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var connz = await response.Content.ReadFromJsonAsync<Connz>();
connz.ShouldNotBeNull();
connz.NumConns.ShouldBe(0);
}
[Fact]
public async Task Connz_filters_by_mqtt_client_for_closed_connections()
{
// Connect then disconnect a client so it appears in closed list
var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _natsPort));
using var stream = new NetworkStream(sock);
var buf = new byte[4096];
_ = await stream.ReadAsync(buf);
await stream.WriteAsync("CONNECT {}\r\n"u8.ToArray());
await Task.Delay(200);
sock.Shutdown(SocketShutdown.Both);
sock.Dispose();
await Task.Delay(500);
// Query closed connections with an MQTT client ID that no connection has
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/connz?state=closed&mqtt_client=missing-id");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var connz = await response.Content.ReadFromJsonAsync<Connz>();
connz.ShouldNotBeNull();
connz.NumConns.ShouldBe(0);
}
[Fact]
public async Task Connz_sort_by_stop_requires_closed_state()
{
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/connz?sort=stop&state=open");
var connz = await response.Content.ReadFromJsonAsync<Connz>();
connz.ShouldNotBeNull();
}
[Fact]
public async Task Connz_sort_by_reason()
{
var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _natsPort));
var buf = new byte[4096];
_ = await sock.ReceiveAsync(buf);
sock.Shutdown(SocketShutdown.Both);
sock.Dispose();
await Task.Delay(500);
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/connz?sort=reason&state=closed");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
}
[Fact]
public async Task Varz_includes_mqtt_section()
{
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/varz");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var varz = await response.Content.ReadFromJsonAsync<Varz>();
varz.ShouldNotBeNull();
varz.Mqtt.ShouldNotBeNull();
varz.Mqtt.Host.ShouldBe("");
varz.Mqtt.Port.ShouldBe(0);
varz.Mqtt.NoAuthUser.ShouldBe("");
varz.Mqtt.JsDomain.ShouldBe("");
varz.Mqtt.AckWait.ShouldBe(0L);
varz.Mqtt.MaxAckPending.ShouldBe((ushort)0);
}
}
public class MonitorTlsTests : IAsyncLifetime
{
private readonly NatsServer _server;
private readonly int _natsPort;
private readonly int _monitorPort;
private readonly CancellationTokenSource _cts = new();
private readonly HttpClient _http = new();
private readonly string _certPath;
private readonly string _keyPath;
public MonitorTlsTests()
{
_natsPort = TestPortAllocator.GetFreePort();
_monitorPort = TestPortAllocator.GetFreePort();
(_certPath, _keyPath) = TestCertHelper.GenerateTestCertFiles();
_server = new NatsServer(
new NatsOptions
{
Port = _natsPort,
MonitorPort = _monitorPort,
TlsCert = _certPath,
TlsKey = _keyPath,
},
NullLoggerFactory.Instance);
}
public async Task InitializeAsync()
{
_ = _server.StartAsync(_cts.Token);
await _server.WaitForReadyAsync();
// Wait for monitoring HTTP server to be ready
for (int i = 0; i < 50; i++)
{
try
{
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/healthz");
if (response.IsSuccessStatusCode) break;
}
catch (HttpRequestException) { }
await Task.Delay(50);
}
}
public async Task DisposeAsync()
{
_http.Dispose();
await _cts.CancelAsync();
_server.Dispose();
File.Delete(_certPath);
File.Delete(_keyPath);
}
[Fact]
public async Task Connz_shows_tls_info_for_tls_client()
{
// Connect and upgrade to TLS
using var tcp = new TcpClient();
await tcp.ConnectAsync(IPAddress.Loopback, _natsPort);
using var netStream = tcp.GetStream();
var buf = new byte[4096];
_ = await netStream.ReadAsync(buf); // Read INFO
using var ssl = new SslStream(netStream, false, (_, _, _, _) => true);
await ssl.AuthenticateAsClientAsync("localhost");
await ssl.WriteAsync("CONNECT {}\r\n"u8.ToArray());
await ssl.FlushAsync();
await Task.Delay(200);
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/connz");
var connz = await response.Content.ReadFromJsonAsync<Connz>();
connz!.Conns.Length.ShouldBeGreaterThanOrEqualTo(1);
var conn = connz.Conns[0];
conn.TlsVersion.ShouldNotBeNullOrEmpty();
conn.TlsCipherSuite.ShouldNotBeNullOrEmpty();
}
}

View File

@@ -0,0 +1,240 @@
// Go reference: server/monitor.go — closedClients ring buffer, ClosedState tracking.
// These tests verify the fixed-size ring buffer used to track recently closed connections
// for the /connz?state=closed monitoring endpoint.
using NATS.Server.Monitoring;
namespace NATS.Server.Monitoring.Tests.Monitoring;
public class ClosedConnectionRingBufferTests
{
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
private static ClosedClient MakeEntry(ulong cid, string reason = "normal") => new()
{
Cid = cid,
Ip = "127.0.0.1",
Port = 4222,
Start = DateTime.UtcNow.AddSeconds(-10),
Stop = DateTime.UtcNow,
Reason = reason,
};
// -----------------------------------------------------------------------
// 1. Add_IncreasesCount
// -----------------------------------------------------------------------
/// <summary>
/// Adding entries should increase Count up to capacity.
/// </summary>
[Fact]
public void Add_IncreasesCount()
{
var buf = new ClosedConnectionRingBuffer(capacity: 10);
buf.Count.ShouldBe(0);
buf.Add(MakeEntry(1));
buf.Count.ShouldBe(1);
buf.Add(MakeEntry(2));
buf.Count.ShouldBe(2);
buf.Add(MakeEntry(3));
buf.Count.ShouldBe(3);
}
// -----------------------------------------------------------------------
// 2. Add_RingOverwrite_CapacityNotExceeded
// -----------------------------------------------------------------------
/// <summary>
/// When capacity is exceeded the count stays at capacity (oldest entry is overwritten).
/// </summary>
[Fact]
public void Add_RingOverwrite_CapacityNotExceeded()
{
const int capacity = 5;
var buf = new ClosedConnectionRingBuffer(capacity);
for (var i = 1; i <= capacity + 3; i++)
buf.Add(MakeEntry((ulong)i));
buf.Count.ShouldBe(capacity);
}
// -----------------------------------------------------------------------
// 3. GetAll_ReturnsNewestFirst
// -----------------------------------------------------------------------
/// <summary>
/// GetAll should return entries ordered newest-first.
/// </summary>
[Fact]
public void GetAll_ReturnsNewestFirst()
{
var buf = new ClosedConnectionRingBuffer(capacity: 10);
buf.Add(MakeEntry(1));
buf.Add(MakeEntry(2));
buf.Add(MakeEntry(3));
var all = buf.GetAll();
all.Count.ShouldBe(3);
all[0].Cid.ShouldBe(3UL); // newest
all[1].Cid.ShouldBe(2UL);
all[2].Cid.ShouldBe(1UL); // oldest
}
// -----------------------------------------------------------------------
// 4. GetAll_EmptyBuffer_ReturnsEmpty
// -----------------------------------------------------------------------
/// <summary>
/// GetAll on an empty buffer should return an empty list.
/// </summary>
[Fact]
public void GetAll_EmptyBuffer_ReturnsEmpty()
{
var buf = new ClosedConnectionRingBuffer(capacity: 10);
var all = buf.GetAll();
all.ShouldBeEmpty();
}
// -----------------------------------------------------------------------
// 5. GetRecent_ReturnsRequestedCount
// -----------------------------------------------------------------------
/// <summary>
/// GetRecent(n) where n &lt;= Count should return exactly n entries.
/// </summary>
[Fact]
public void GetRecent_ReturnsRequestedCount()
{
var buf = new ClosedConnectionRingBuffer(capacity: 10);
for (var i = 1; i <= 8; i++)
buf.Add(MakeEntry((ulong)i));
var recent = buf.GetRecent(3);
recent.Count.ShouldBe(3);
recent[0].Cid.ShouldBe(8UL); // newest
recent[1].Cid.ShouldBe(7UL);
recent[2].Cid.ShouldBe(6UL);
}
// -----------------------------------------------------------------------
// 6. GetRecent_LessThanAvailable_ReturnsAll
// -----------------------------------------------------------------------
/// <summary>
/// GetRecent(n) where n &gt; Count should return all available entries.
/// </summary>
[Fact]
public void GetRecent_LessThanAvailable_ReturnsAll()
{
var buf = new ClosedConnectionRingBuffer(capacity: 10);
buf.Add(MakeEntry(1));
buf.Add(MakeEntry(2));
var recent = buf.GetRecent(100);
recent.Count.ShouldBe(2);
}
// -----------------------------------------------------------------------
// 7. TotalClosed_TracksAllAdditions
// -----------------------------------------------------------------------
/// <summary>
/// TotalClosed should increment for every Add, even after the ring wraps around.
/// </summary>
[Fact]
public void TotalClosed_TracksAllAdditions()
{
const int capacity = 4;
var buf = new ClosedConnectionRingBuffer(capacity);
buf.TotalClosed.ShouldBe(0L);
for (var i = 1; i <= 10; i++)
buf.Add(MakeEntry((ulong)i));
buf.TotalClosed.ShouldBe(10L);
buf.Count.ShouldBe(capacity); // buffer is full but total reflects all 10
}
// -----------------------------------------------------------------------
// 8. Clear_ResetsCountAndBuffer
// -----------------------------------------------------------------------
/// <summary>
/// Clear should reset Count to zero and GetAll should return an empty list.
/// TotalClosed is intentionally not reset because it is a running lifetime counter.
/// </summary>
[Fact]
public void Clear_ResetsCountAndBuffer()
{
var buf = new ClosedConnectionRingBuffer(capacity: 10);
buf.Add(MakeEntry(1));
buf.Add(MakeEntry(2));
buf.Add(MakeEntry(3));
buf.TotalClosed.ShouldBe(3L);
buf.Clear();
buf.Count.ShouldBe(0);
buf.GetAll().ShouldBeEmpty();
// TotalClosed is a lifetime counter; it is not reset by Clear.
buf.TotalClosed.ShouldBe(3L);
}
// -----------------------------------------------------------------------
// 9. Capacity_ReturnsConfiguredSize
// -----------------------------------------------------------------------
/// <summary>
/// Capacity should reflect the value passed to the constructor.
/// </summary>
[Fact]
public void Capacity_ReturnsConfiguredSize()
{
var buf = new ClosedConnectionRingBuffer(capacity: 42);
buf.Capacity.ShouldBe(42);
}
// -----------------------------------------------------------------------
// 10. Add_WrapsCorrectly
// -----------------------------------------------------------------------
/// <summary>
/// After adding capacity+1 items the oldest entry (cid=1) should no longer be present,
/// and the buffer should contain the most recent 'capacity' items.
/// </summary>
[Fact]
public void Add_WrapsCorrectly()
{
const int capacity = 5;
var buf = new ClosedConnectionRingBuffer(capacity);
for (var i = 1; i <= capacity + 1; i++)
buf.Add(MakeEntry((ulong)i));
var all = buf.GetAll();
all.Count.ShouldBe(capacity);
// cid=1 (the oldest) should have been overwritten
all.Any(e => e.Cid == 1UL).ShouldBeFalse();
// The newest entry (cid=capacity+1) should be first
all[0].Cid.ShouldBe((ulong)(capacity + 1));
}
}

View File

@@ -0,0 +1,152 @@
// Go reference: server/monitor.go ClosedState.String() — reason strings emitted by
// the /connz endpoint, and server/auth.go getAuthErrClosedState — auth-related reasons.
// These tests verify the ClosedReason enum and ClosedReasonHelper helpers introduced
// in Task 89 (Gap 10.7: consistently populate closed connection reasons).
using NATS.Server.Monitoring;
namespace NATS.Server.Monitoring.Tests.Monitoring;
public class ClosedReasonTests
{
// -----------------------------------------------------------------------
// 1. ToReasonString_ClientClosed_ReturnsExpected
// -----------------------------------------------------------------------
/// <summary>
/// ClientClosed maps to the Go-compatible "Client Closed" string.
/// Go reference: server/monitor.go ClosedState.String() case ClientClosed.
/// </summary>
[Fact]
public void ToReasonString_ClientClosed_ReturnsExpected()
{
ClosedReasonHelper.ToReasonString(ClosedReason.ClientClosed).ShouldBe("Client Closed");
}
// -----------------------------------------------------------------------
// 2. ToReasonString_AllReasonsHaveStrings
// -----------------------------------------------------------------------
/// <summary>
/// Every ClosedReason enum value must produce a non-null, non-empty string.
/// Go reference: server/monitor.go ClosedState.String() — all cases covered.
/// </summary>
[Fact]
public void ToReasonString_AllReasonsHaveStrings()
{
foreach (var reason in Enum.GetValues<ClosedReason>())
{
var s = ClosedReasonHelper.ToReasonString(reason);
s.ShouldNotBeNull();
s.ShouldNotBeEmpty();
}
}
// -----------------------------------------------------------------------
// 3. FromReasonString_ValidString_ReturnsEnum
// -----------------------------------------------------------------------
/// <summary>
/// A valid Go-compatible reason string parses back to the correct enum value.
/// Go reference: server/monitor.go ClosedState.String() "Server Shutdown".
/// </summary>
[Fact]
public void FromReasonString_ValidString_ReturnsEnum()
{
ClosedReasonHelper.FromReasonString("Server Shutdown").ShouldBe(ClosedReason.ServerShutdown);
}
// -----------------------------------------------------------------------
// 4. FromReasonString_Unknown_ReturnsUnknown
// -----------------------------------------------------------------------
/// <summary>
/// An unrecognised string returns ClosedReason.Unknown.
/// </summary>
[Fact]
public void FromReasonString_Unknown_ReturnsUnknown()
{
ClosedReasonHelper.FromReasonString("Not a real reason").ShouldBe(ClosedReason.Unknown);
}
// -----------------------------------------------------------------------
// 5. FromReasonString_Null_ReturnsUnknown
// -----------------------------------------------------------------------
/// <summary>
/// A null reason string returns ClosedReason.Unknown.
/// </summary>
[Fact]
public void FromReasonString_Null_ReturnsUnknown()
{
ClosedReasonHelper.FromReasonString(null).ShouldBe(ClosedReason.Unknown);
}
// -----------------------------------------------------------------------
// 6. IsClientInitiated_ClientClosed_True
// -----------------------------------------------------------------------
/// <summary>
/// ClientClosed is the only client-initiated close reason.
/// Go reference: server/client.go closeConnection — client disconnect path.
/// </summary>
[Fact]
public void IsClientInitiated_ClientClosed_True()
{
ClosedReasonHelper.IsClientInitiated(ClosedReason.ClientClosed).ShouldBeTrue();
}
// -----------------------------------------------------------------------
// 7. IsClientInitiated_ServerShutdown_False
// -----------------------------------------------------------------------
/// <summary>
/// ServerShutdown is not a client-initiated close.
/// </summary>
[Fact]
public void IsClientInitiated_ServerShutdown_False()
{
ClosedReasonHelper.IsClientInitiated(ClosedReason.ServerShutdown).ShouldBeFalse();
}
// -----------------------------------------------------------------------
// 8. IsAuthRelated_AuthTimeout_True
// -----------------------------------------------------------------------
/// <summary>
/// AuthTimeout is auth-related.
/// Go reference: server/auth.go getAuthErrClosedState — auth timeout path.
/// </summary>
[Fact]
public void IsAuthRelated_AuthTimeout_True()
{
ClosedReasonHelper.IsAuthRelated(ClosedReason.AuthTimeout).ShouldBeTrue();
}
// -----------------------------------------------------------------------
// 9. IsAuthRelated_WriteError_False
// -----------------------------------------------------------------------
/// <summary>
/// WriteError is not auth-related.
/// </summary>
[Fact]
public void IsAuthRelated_WriteError_False()
{
ClosedReasonHelper.IsAuthRelated(ClosedReason.WriteError).ShouldBeFalse();
}
// -----------------------------------------------------------------------
// 10. IsResourceLimit_MaxConnections_True
// -----------------------------------------------------------------------
/// <summary>
/// MaxConnectionsExceeded is a resource-limit close reason.
/// Go reference: server/client.go maxConnectionsExceeded — max connections path.
/// </summary>
[Fact]
public void IsResourceLimit_MaxConnections_True()
{
ClosedReasonHelper.IsResourceLimit(ClosedReason.MaxConnectionsExceeded).ShouldBeTrue();
}
}

View File

@@ -0,0 +1,190 @@
using NATS.Server.Monitoring;
namespace NATS.Server.Monitoring.Tests.Monitoring;
/// <summary>
/// Unit tests for account-scoped /connz filtering (Gap 10.2).
/// Exercises ConnzFilterOptions.Parse, ConnzFilter.FilterByAccount, and
/// ConnzFilter.ApplyFilters in isolation — no running server required.
/// Go reference: monitor_test.go — TestConnzFilterByAccount, TestConnzWithAccount.
/// </summary>
public class ConnzAccountFilterTests
{
// -----------------------------------------------------------------------
// Helper — build a ConnzConnectionInfo with sensible defaults
// -----------------------------------------------------------------------
private static ConnzConnectionInfo MakeConn(
ulong clientId,
string? accountName,
string remoteAddress = "127.0.0.1:1234",
string? name = null,
long inMsgs = 0,
long outMsgs = 0,
long inBytes = 0,
long outBytes = 0) =>
new(clientId, remoteAddress, accountName, name, DateTime.UtcNow,
inMsgs, outMsgs, inBytes, outBytes);
// -----------------------------------------------------------------------
// FilterByAccount tests
// -----------------------------------------------------------------------
[Fact]
public void FilterByAccount_MatchingAccount_ReturnsFiltered()
{
// Go reference: monitor.go Connz() — "if opts.Account != "" { ... }" filter
var connections = new[]
{
MakeConn(1, "acctA"),
MakeConn(2, "acctB"),
MakeConn(3, "acctA"),
};
var result = ConnzFilter.FilterByAccount(connections, "acctA");
result.Count.ShouldBe(2);
result.ShouldAllBe(c => c.AccountName == "acctA");
}
[Fact]
public void FilterByAccount_NoMatch_ReturnsEmpty()
{
var connections = new[]
{
MakeConn(1, "acctA"),
MakeConn(2, "acctB"),
};
var result = ConnzFilter.FilterByAccount(connections, "acctC");
result.ShouldBeEmpty();
}
[Fact]
public void FilterByAccount_CaseInsensitive()
{
// Go reference: monitor.go — account name comparison is case-insensitive
var connections = new[]
{
MakeConn(1, "AcctA"),
MakeConn(2, "acctb"),
};
// Upper-case query against lower-case stored name
var lowerResult = ConnzFilter.FilterByAccount(connections, "accta");
lowerResult.Count.ShouldBe(1);
lowerResult[0].ClientId.ShouldBe(1UL);
// Lower-case query against mixed-case stored name
var upperResult = ConnzFilter.FilterByAccount(connections, "ACCTB");
upperResult.Count.ShouldBe(1);
upperResult[0].ClientId.ShouldBe(2UL);
}
// -----------------------------------------------------------------------
// ConnzFilterOptions.Parse tests
// -----------------------------------------------------------------------
[Fact]
public void Parse_WithAccParam_SetsAccountFilter()
{
var opts = ConnzFilterOptions.Parse("?acc=myAccount");
opts.AccountFilter.ShouldBe("myAccount");
}
[Fact]
public void Parse_WithoutParams_DefaultValues()
{
var opts = ConnzFilterOptions.Parse(null);
opts.AccountFilter.ShouldBeNull();
opts.StateFilter.ShouldBeNull();
opts.Offset.ShouldBe(0);
opts.Limit.ShouldBe(1024);
}
[Fact]
public void Parse_WithOffsetAndLimit_ParsesCorrectly()
{
var opts = ConnzFilterOptions.Parse("?acc=acctA&state=open&offset=10&limit=50");
opts.AccountFilter.ShouldBe("acctA");
opts.StateFilter.ShouldBe("open");
opts.Offset.ShouldBe(10);
opts.Limit.ShouldBe(50);
}
// -----------------------------------------------------------------------
// ApplyFilters tests
// -----------------------------------------------------------------------
[Fact]
public void ApplyFilters_WithAccountFilter_FiltersCorrectly()
{
var connections = new[]
{
MakeConn(1, "acctA"),
MakeConn(2, "acctB"),
MakeConn(3, "acctA"),
};
var opts = new ConnzFilterOptions { AccountFilter = "acctA" };
var result = ConnzFilter.ApplyFilters(connections, opts);
result.Total.ShouldBe(2);
result.Connections.Count.ShouldBe(2);
result.Connections.ShouldAllBe(c => c.AccountName == "acctA");
}
[Fact]
public void ApplyFilters_WithOffset_SkipsEntries()
{
var connections = Enumerable.Range(1, 5)
.Select(i => MakeConn((ulong)i, "acctA"))
.ToList();
var opts = new ConnzFilterOptions { Offset = 3, Limit = 10 };
var result = ConnzFilter.ApplyFilters(connections, opts);
result.Total.ShouldBe(5);
result.Connections.Count.ShouldBe(2);
result.Offset.ShouldBe(3);
// The paged items should be the last two (clientId 4 and 5)
result.Connections[0].ClientId.ShouldBe(4UL);
result.Connections[1].ClientId.ShouldBe(5UL);
}
[Fact]
public void ApplyFilters_WithLimit_CapsResults()
{
var connections = Enumerable.Range(1, 10)
.Select(i => MakeConn((ulong)i, "acctA"))
.ToList();
var opts = new ConnzFilterOptions { Limit = 3 };
var result = ConnzFilter.ApplyFilters(connections, opts);
result.Total.ShouldBe(10);
result.Connections.Count.ShouldBe(3);
result.Limit.ShouldBe(3);
}
[Fact]
public void ApplyFilters_NoFilters_ReturnsAll()
{
var connections = new[]
{
MakeConn(1, "acctA"),
MakeConn(2, "acctB"),
MakeConn(3, null),
};
var opts = new ConnzFilterOptions();
var result = ConnzFilter.ApplyFilters(connections, opts);
result.Total.ShouldBe(3);
result.Connections.Count.ShouldBe(3);
}
}

View File

@@ -0,0 +1,393 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server;
using NATS.Server.Auth;
using NATS.Server.Monitoring;
using NATS.Server.TestUtilities;
namespace NATS.Server.Monitoring.Tests.Monitoring;
/// <summary>
/// Tests for ConnzHandler filtering, sorting, pagination, and closed connection
/// ring buffer behavior.
/// Go reference: monitor_test.go — TestConnz, TestConnzSortedByCid, TestConnzSortedByBytesTo,
/// TestConnzFilter, TestConnzWithCID, TestConnzOffsetAndLimit.
/// </summary>
public class ConnzFilterTests : IAsyncLifetime
{
private readonly NatsServer _server;
private readonly NatsOptions _opts;
private readonly CancellationTokenSource _cts = new();
private readonly List<Socket> _sockets = [];
public ConnzFilterTests()
{
_opts = new NatsOptions
{
Port = TestPortAllocator.GetFreePort(),
MaxClosedClients = 100,
Users =
[
new User { Username = "alice", Password = "pw", Account = "acctA" },
new User { Username = "bob", Password = "pw", Account = "acctB" },
],
};
_server = new NatsServer(_opts, NullLoggerFactory.Instance);
}
public async Task InitializeAsync()
{
_ = _server.StartAsync(_cts.Token);
await _server.WaitForReadyAsync();
}
public async Task DisposeAsync()
{
foreach (var s in _sockets)
{
try { s.Shutdown(SocketShutdown.Both); } catch { }
s.Dispose();
}
await _cts.CancelAsync();
_server.Dispose();
}
private async Task<Socket> ConnectAsync(string user, string? subjectToSubscribe = null)
{
var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
_sockets.Add(sock);
await sock.ConnectAsync(IPAddress.Loopback, _opts.Port);
var buf = new byte[4096];
await sock.ReceiveAsync(buf, SocketFlags.None); // INFO
var connect = $"CONNECT {{\"user\":\"{user}\",\"pass\":\"pw\"}}\r\n";
await sock.SendAsync(Encoding.ASCII.GetBytes(connect));
if (subjectToSubscribe != null)
{
await sock.SendAsync(Encoding.ASCII.GetBytes($"SUB {subjectToSubscribe} sid1\r\n"));
}
await sock.SendAsync("PING\r\n"u8.ToArray());
await SocketTestHelper.ReadUntilAsync(sock, "PONG");
return sock;
}
private Connz GetConnz(string queryString = "")
{
var ctx = new DefaultHttpContext();
ctx.Request.QueryString = new QueryString(queryString);
return new ConnzHandler(_server).HandleConnz(ctx);
}
// --- Sort tests ---
[Fact]
public async Task Sort_by_cid_returns_ascending_order()
{
await ConnectAsync("alice");
await ConnectAsync("bob");
await Task.Delay(50);
var connz = GetConnz("?sort=cid");
connz.Conns.Length.ShouldBeGreaterThanOrEqualTo(2);
for (int i = 1; i < connz.Conns.Length; i++)
{
connz.Conns[i].Cid.ShouldBeGreaterThan(connz.Conns[i - 1].Cid);
}
}
[Fact]
public async Task Sort_by_bytes_to_returns_descending_order()
{
var sock1 = await ConnectAsync("alice");
var sock2 = await ConnectAsync("bob");
await Task.Delay(50);
// Publish some data through sock1 to accumulate bytes
await sock1.SendAsync(Encoding.ASCII.GetBytes("SUB test 1\r\nPUB test 10\r\n1234567890\r\n"));
await Task.Delay(100);
var connz = GetConnz("?sort=bytes_to");
connz.Conns.Length.ShouldBeGreaterThanOrEqualTo(2);
for (int i = 1; i < connz.Conns.Length; i++)
{
connz.Conns[i].OutBytes.ShouldBeLessThanOrEqualTo(connz.Conns[i - 1].OutBytes);
}
}
[Fact]
public async Task Sort_by_msgs_from_returns_descending_order()
{
var sock1 = await ConnectAsync("alice");
await Task.Delay(50);
// Send a PUB to increment InMsgs
await sock1.SendAsync(Encoding.ASCII.GetBytes("PUB test 3\r\nabc\r\n"));
await Task.Delay(100);
var connz = GetConnz("?sort=msgs_from");
connz.Conns.Length.ShouldBeGreaterThanOrEqualTo(1);
for (int i = 1; i < connz.Conns.Length; i++)
{
connz.Conns[i].InMsgs.ShouldBeLessThanOrEqualTo(connz.Conns[i - 1].InMsgs);
}
}
[Fact]
public async Task Sort_by_subs_returns_descending_order()
{
// Alice has 2 subs, Bob has 1
var sock1 = await ConnectAsync("alice", "test.a");
await sock1.SendAsync(Encoding.ASCII.GetBytes("SUB test.b sid2\r\n"));
var sock2 = await ConnectAsync("bob", "test.c");
await Task.Delay(100);
var connz = GetConnz("?sort=subs");
connz.Conns.Length.ShouldBeGreaterThanOrEqualTo(2);
for (int i = 1; i < connz.Conns.Length; i++)
{
connz.Conns[i].NumSubs.ShouldBeLessThanOrEqualTo(connz.Conns[i - 1].NumSubs);
}
}
[Fact]
public async Task Sort_by_start_returns_ascending_order()
{
await ConnectAsync("alice");
await Task.Delay(20);
await ConnectAsync("bob");
await Task.Delay(50);
var connz = GetConnz("?sort=start");
connz.Conns.Length.ShouldBeGreaterThanOrEqualTo(2);
for (int i = 1; i < connz.Conns.Length; i++)
{
connz.Conns[i].Start.ShouldBeGreaterThanOrEqualTo(connz.Conns[i - 1].Start);
}
}
// --- Filter tests ---
[Fact]
public async Task Filter_by_account_returns_only_matching_connections()
{
await ConnectAsync("alice");
await ConnectAsync("bob");
await Task.Delay(50);
var connz = GetConnz("?acc=acctA");
connz.Conns.ShouldAllBe(c => c.Account == "acctA");
connz.Conns.ShouldNotBeEmpty();
}
[Fact]
public async Task Filter_by_user_returns_only_matching_connections()
{
await ConnectAsync("alice");
await ConnectAsync("bob");
await Task.Delay(50);
var connz = GetConnz("?user=bob");
connz.Conns.ShouldAllBe(c => c.AuthorizedUser == "bob");
connz.Conns.ShouldNotBeEmpty();
}
[Fact]
public async Task Filter_by_subject_returns_matching_subscribers()
{
await ConnectAsync("alice", "orders.>");
await ConnectAsync("bob", "payments.>");
await Task.Delay(50);
var connz = GetConnz("?filter_subject=orders.new&subs=1");
connz.Conns.ShouldNotBeEmpty();
connz.Conns.ShouldAllBe(c => c.Subs.Any(s => s.Contains("orders")));
}
// --- Pagination tests ---
[Fact]
public async Task Offset_and_limit_paginates_results()
{
await ConnectAsync("alice");
await ConnectAsync("bob");
await ConnectAsync("alice");
await Task.Delay(50);
var page1 = GetConnz("?sort=cid&limit=2&offset=0");
page1.Conns.Length.ShouldBe(2);
page1.Total.ShouldBeGreaterThanOrEqualTo(3);
page1.Offset.ShouldBe(0);
page1.Limit.ShouldBe(2);
var page2 = GetConnz("?sort=cid&limit=2&offset=2");
page2.Conns.Length.ShouldBeGreaterThanOrEqualTo(1);
page2.Offset.ShouldBe(2);
// Ensure no overlap between pages
var page1Cids = page1.Conns.Select(c => c.Cid).ToHashSet();
var page2Cids = page2.Conns.Select(c => c.Cid).ToHashSet();
page1Cids.Overlaps(page2Cids).ShouldBeFalse();
}
// --- CID lookup test ---
[Fact]
public async Task Cid_lookup_returns_single_connection()
{
await ConnectAsync("alice");
await ConnectAsync("bob");
await Task.Delay(50);
// Get all connections to find a known CID
var all = GetConnz("?sort=cid");
all.Conns.ShouldNotBeEmpty();
var targetCid = all.Conns[0].Cid;
var single = GetConnz($"?cid={targetCid}");
single.Conns.Length.ShouldBe(1);
single.Conns[0].Cid.ShouldBe(targetCid);
}
[Fact]
public void Cid_lookup_nonexistent_returns_empty()
{
var result = GetConnz("?cid=99999999");
result.Conns.Length.ShouldBe(0);
result.Total.ShouldBe(0);
}
// --- Closed connection tests ---
[Fact]
public async Task Closed_state_shows_disconnected_clients()
{
var sock = await ConnectAsync("alice");
await Task.Delay(50);
// Close the connection
sock.Shutdown(SocketShutdown.Both);
sock.Close();
_sockets.Remove(sock);
await Task.Delay(200);
var connz = GetConnz("?state=closed");
connz.Conns.ShouldNotBeEmpty();
connz.Conns.ShouldAllBe(c => c.Stop != null);
connz.Conns.ShouldAllBe(c => !string.IsNullOrEmpty(c.Reason));
}
[Fact]
public async Task All_state_shows_both_open_and_closed()
{
var sock1 = await ConnectAsync("alice");
var sock2 = await ConnectAsync("bob");
await Task.Delay(50);
// Close one connection
sock1.Shutdown(SocketShutdown.Both);
sock1.Close();
_sockets.Remove(sock1);
await Task.Delay(200);
var connz = GetConnz("?state=all");
connz.Total.ShouldBeGreaterThanOrEqualTo(2);
// Should have at least one open (bob) and one closed (alice)
connz.Conns.Any(c => c.Stop == null).ShouldBeTrue("expected at least one open connection");
connz.Conns.Any(c => c.Stop != null).ShouldBeTrue("expected at least one closed connection");
}
[Fact]
public async Task Closed_ring_buffer_caps_at_max()
{
// MaxClosedClients is 100, create and close 5 connections
for (int i = 0; i < 5; i++)
{
var sock = await ConnectAsync("alice");
await Task.Delay(20);
sock.Shutdown(SocketShutdown.Both);
sock.Close();
_sockets.Remove(sock);
await Task.Delay(100);
}
var connz = GetConnz("?state=closed");
connz.Total.ShouldBeLessThanOrEqualTo(_opts.MaxClosedClients);
}
// --- Sort fallback tests ---
[Fact]
public async Task Sort_by_stop_with_open_state_falls_back_to_cid()
{
await ConnectAsync("alice");
await ConnectAsync("bob");
await Task.Delay(50);
// sort=stop with state=open should fall back to cid sorting
var connz = GetConnz("?sort=stop&state=open");
connz.Conns.Length.ShouldBeGreaterThanOrEqualTo(2);
for (int i = 1; i < connz.Conns.Length; i++)
{
connz.Conns[i].Cid.ShouldBeGreaterThan(connz.Conns[i - 1].Cid);
}
}
// --- Combined filter + sort test ---
[Fact]
public async Task Account_filter_with_bytes_sort_and_limit()
{
// Connect multiple alice clients
for (int i = 0; i < 3; i++)
{
var sock = await ConnectAsync("alice");
// Send varying amounts of data
var data = new string('x', (i + 1) * 100);
await sock.SendAsync(Encoding.ASCII.GetBytes($"SUB test 1\r\nPUB test {data.Length}\r\n{data}\r\n"));
}
await ConnectAsync("bob");
await Task.Delay(100);
var connz = GetConnz("?acc=acctA&sort=bytes_to&limit=2");
connz.Conns.Length.ShouldBeLessThanOrEqualTo(2);
connz.Conns.ShouldAllBe(c => c.Account == "acctA");
}
[Fact]
public async Task Closed_cid_lookup_returns_from_ring_buffer()
{
var sock = await ConnectAsync("alice");
await Task.Delay(50);
// Get the CID before closing
var all = GetConnz("?sort=cid");
all.Conns.ShouldNotBeEmpty();
var targetCid = all.Conns.Last().Cid;
// Close the socket
sock.Shutdown(SocketShutdown.Both);
sock.Close();
_sockets.Remove(sock);
await Task.Delay(200);
// Look up closed connection by CID
var single = GetConnz($"?cid={targetCid}");
single.Conns.Length.ShouldBe(1);
single.Conns[0].Cid.ShouldBe(targetCid);
single.Conns[0].Stop.ShouldNotBeNull();
}
}

View File

@@ -0,0 +1,54 @@
using System.Text.Json;
using System.Text;
namespace NATS.Server.Monitoring.Tests;
public class ConnzParityFieldTests
{
[Fact]
public async Task Connz_includes_identity_tls_and_proxy_parity_fields()
{
await using var fx = await MonitoringParityFixture.StartAsync();
var jwt = BuildJwt("UISSUER", ["team:core", "tier:gold"]);
await fx.ConnectClientAsync("proxy:edge", "orders.created", jwt);
var connz = fx.GetConnz("?subs=detail&auth=true");
connz.Conns.ShouldNotBeEmpty();
var conn = connz.Conns.Single(c => c.AuthorizedUser == "proxy:edge");
conn.Proxy.ShouldNotBeNull();
conn.Proxy.Key.ShouldBe("edge");
conn.Jwt.ShouldBe(jwt);
conn.IssuerKey.ShouldBe("UISSUER");
conn.Tags.ShouldContain("team:core");
var json = JsonSerializer.Serialize(connz);
json.ShouldContain("tls_peer_cert_subject");
json.ShouldContain("tls_peer_certs");
json.ShouldContain("issuer_key");
json.ShouldContain("\"tags\"");
json.ShouldContain("proxy");
json.ShouldNotContain("jwt_issuer_key");
}
private static string BuildJwt(string issuer, string[] tags)
{
static string B64Url(string json)
{
return Convert.ToBase64String(Encoding.UTF8.GetBytes(json))
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
var header = B64Url("{\"alg\":\"none\",\"typ\":\"JWT\"}");
var payload = B64Url(JsonSerializer.Serialize(new
{
iss = issuer,
nats = new
{
tags,
},
}));
return $"{header}.{payload}.eA";
}
}

View File

@@ -0,0 +1,119 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server.Auth;
using NATS.Server.Monitoring;
namespace NATS.Server.Monitoring.Tests;
public class ConnzParityFilterTests
{
[Fact]
public async Task Connz_filters_by_user_account_and_subject_and_includes_tls_peer_and_jwt_metadata()
{
await using var fx = await MonitoringParityFixture.StartAsync();
await fx.ConnectClientAsync("u", "orders.created");
await fx.ConnectClientAsync("v", "payments.created");
var connz = fx.GetConnz("?user=u&acc=A&filter_subject=orders.*&subs=detail");
connz.Conns.ShouldAllBe(c => c.Account == "A" && c.AuthorizedUser == "u");
}
}
internal sealed class MonitoringParityFixture : IAsyncDisposable
{
private readonly NatsServer _server;
private readonly CancellationTokenSource _cts;
private readonly List<TcpClient> _clients = [];
private readonly NatsOptions _options;
private MonitoringParityFixture(NatsServer server, NatsOptions options, CancellationTokenSource cts)
{
_server = server;
_options = options;
_cts = cts;
}
public static async Task<MonitoringParityFixture> StartAsync()
{
var options = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
Users =
[
new User { Username = "u", Password = "p", Account = "A" },
new User { Username = "v", Password = "p", Account = "B" },
new User { Username = "proxy:edge", Password = "p", Account = "A" },
],
};
var server = new NatsServer(options, NullLoggerFactory.Instance);
var cts = new CancellationTokenSource();
_ = server.StartAsync(cts.Token);
await server.WaitForReadyAsync();
return new MonitoringParityFixture(server, options, cts);
}
public async Task ConnectClientAsync(string username, string? subscribeSubject, string? jwt = null)
{
var client = new TcpClient();
await client.ConnectAsync(IPAddress.Loopback, _server.Port);
_clients.Add(client);
var stream = client.GetStream();
await ReadLineAsync(stream); // INFO
var connectPayload = string.IsNullOrWhiteSpace(jwt)
? $"{{\"user\":\"{username}\",\"pass\":\"p\"}}"
: $"{{\"user\":\"{username}\",\"pass\":\"p\",\"jwt\":\"{jwt}\"}}";
var connect = $"CONNECT {connectPayload}\r\n";
await stream.WriteAsync(Encoding.ASCII.GetBytes(connect));
if (!string.IsNullOrEmpty(subscribeSubject))
await stream.WriteAsync(Encoding.ASCII.GetBytes($"SUB {subscribeSubject} sid-{username}\r\n"));
await stream.FlushAsync();
await Task.Delay(30);
}
public Connz GetConnz(string queryString)
{
var ctx = new DefaultHttpContext();
ctx.Request.QueryString = new QueryString(queryString);
return new ConnzHandler(_server).HandleConnz(ctx);
}
public async Task<Varz> GetVarzAsync()
{
using var handler = new VarzHandler(_server, _options, NullLoggerFactory.Instance);
return await handler.HandleVarzAsync();
}
public async ValueTask DisposeAsync()
{
foreach (var client in _clients)
client.Dispose();
await _cts.CancelAsync();
_server.Dispose();
_cts.Dispose();
}
private static async Task<string> ReadLineAsync(NetworkStream stream)
{
var bytes = new List<byte>();
var one = new byte[1];
while (true)
{
var read = await stream.ReadAsync(one.AsMemory(0, 1));
if (read == 0)
break;
if (one[0] == (byte)'\n')
break;
if (one[0] != (byte)'\r')
bytes.Add(one[0]);
}
return Encoding.ASCII.GetString([.. bytes]);
}
}

View File

@@ -0,0 +1,171 @@
// Ported from golang/nats-server/server/monitor_test.go
// TestMonitorConnz — verify /connz lists active connections with correct fields.
// TestMonitorConnzSortedByBytesAndMsgs — verify /connz?sort=bytes_to ordering.
using System.Net;
using System.Net.Http.Json;
using System.Net.Sockets;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server.Monitoring;
using NATS.Server.TestUtilities;
namespace NATS.Server.Monitoring.Tests;
public class ConnzParityTests : IAsyncLifetime
{
private readonly NatsServer _server;
private readonly int _natsPort;
private readonly int _monitorPort;
private readonly CancellationTokenSource _cts = new();
private readonly HttpClient _http = new();
public ConnzParityTests()
{
_natsPort = TestPortAllocator.GetFreePort();
_monitorPort = TestPortAllocator.GetFreePort();
_server = new NatsServer(
new NatsOptions { Port = _natsPort, MonitorPort = _monitorPort },
NullLoggerFactory.Instance);
}
public async Task InitializeAsync()
{
_ = _server.StartAsync(_cts.Token);
await _server.WaitForReadyAsync();
for (var i = 0; i < 50; i++)
{
try
{
var probe = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/healthz");
if (probe.IsSuccessStatusCode) break;
}
catch (HttpRequestException) { }
await Task.Delay(50);
}
}
public async Task DisposeAsync()
{
_http.Dispose();
await _cts.CancelAsync();
_server.Dispose();
}
/// <summary>
/// Corresponds to Go TestMonitorConnz.
/// Verifies /connz lists active connections and that per-connection fields
/// (ip, port, lang, version, uptime) are populated once 2 clients are connected.
/// </summary>
[Fact]
public async Task Connz_lists_active_connections()
{
var sockets = new List<Socket>();
try
{
// Connect 2 named clients
for (var i = 0; i < 2; i++)
{
var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _natsPort));
var ns = new NetworkStream(sock);
var buf = new byte[4096];
_ = await ns.ReadAsync(buf); // consume INFO
var connect = $"CONNECT {{\"name\":\"client-{i}\",\"lang\":\"csharp\",\"version\":\"1.0\"}}\r\n";
await ns.WriteAsync(System.Text.Encoding.ASCII.GetBytes(connect));
await ns.FlushAsync();
sockets.Add(sock);
}
await Task.Delay(200);
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/connz");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var connz = await response.Content.ReadFromJsonAsync<Connz>();
connz.ShouldNotBeNull();
// Both clients must appear
connz.NumConns.ShouldBeGreaterThanOrEqualTo(2);
connz.Conns.Length.ShouldBeGreaterThanOrEqualTo(2);
// Verify per-connection identity fields on one of our named connections
var conn = connz.Conns.First(c => c.Name == "client-0");
conn.Ip.ShouldNotBeNullOrEmpty();
conn.Port.ShouldBeGreaterThan(0);
conn.Lang.ShouldBe("csharp");
conn.Version.ShouldBe("1.0");
conn.Uptime.ShouldNotBeNullOrEmpty();
}
finally
{
foreach (var s in sockets) s.Dispose();
}
}
/// <summary>
/// Corresponds to Go TestMonitorConnzSortedByBytesAndMsgs (bytes_to / out_bytes ordering).
/// Connects a high-traffic client that publishes 100 messages and 3 baseline clients,
/// then verifies /connz?sort=bytes_to returns connections in descending out_bytes order.
/// </summary>
[Fact]
public async Task Connz_sort_by_bytes()
{
var sockets = new List<(Socket Sock, NetworkStream Ns)>();
try
{
// Connect a subscriber first so that published messages are delivered (and counted as out_bytes)
var subSock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await subSock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _natsPort));
var subNs = new NetworkStream(subSock);
var subBuf = new byte[4096];
_ = await subNs.ReadAsync(subBuf);
await subNs.WriteAsync("CONNECT {}\r\nSUB foo 1\r\n"u8.ToArray());
await subNs.FlushAsync();
sockets.Add((subSock, subNs));
// High-traffic publisher: publish 100 messages to "foo"
var highSock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await highSock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _natsPort));
var highNs = new NetworkStream(highSock);
var highBuf = new byte[4096];
_ = await highNs.ReadAsync(highBuf);
await highNs.WriteAsync("CONNECT {}\r\n"u8.ToArray());
await highNs.FlushAsync();
for (var i = 0; i < 100; i++)
await highNs.WriteAsync("PUB foo 11\r\nHello World\r\n"u8.ToArray());
await highNs.FlushAsync();
sockets.Add((highSock, highNs));
// 3 baseline clients — no traffic beyond CONNECT
for (var i = 0; i < 3; i++)
{
var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _natsPort));
var ns = new NetworkStream(sock);
var buf = new byte[4096];
_ = await ns.ReadAsync(buf);
await ns.WriteAsync("CONNECT {}\r\n"u8.ToArray());
await ns.FlushAsync();
sockets.Add((sock, ns));
}
await Task.Delay(300);
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/connz?sort=bytes_to");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var connz = await response.Content.ReadFromJsonAsync<Connz>();
connz.ShouldNotBeNull();
connz.Conns.Length.ShouldBeGreaterThanOrEqualTo(2);
// The first entry must have at least as many out_bytes as the second (descending order)
connz.Conns[0].OutBytes.ShouldBeGreaterThanOrEqualTo(connz.Conns[1].OutBytes);
}
finally
{
foreach (var (s, _) in sockets) s.Dispose();
}
}
}

View File

@@ -0,0 +1,179 @@
using NATS.Server.Monitoring;
namespace NATS.Server.Monitoring.Tests.Monitoring;
/// <summary>
/// Unit tests for ConnzSortOption enum, ConnzSorter.Parse, and ConnzSorter.Sort.
/// All tests exercise the types in isolation — no running server required.
/// Go reference: monitor_test.go — TestConnzSortedByCid, TestConnzSortedByBytesTo,
/// TestConnzSortedByMsgsFrom, TestConnzSortedByStart, monitor_sort_opts.go.
/// </summary>
public class ConnzSortTests
{
// -----------------------------------------------------------------------
// Helper — build a ConnInfo with specific field values
// -----------------------------------------------------------------------
private static ConnInfo MakeConn(
ulong cid = 1,
long outBytes = 0,
long inBytes = 0,
long outMsgs = 0,
long inMsgs = 0,
uint numSubs = 0,
int pending = 0,
DateTime connectedAt = default,
DateTime lastActivity = default) =>
new()
{
Cid = cid,
OutBytes = outBytes,
InBytes = inBytes,
OutMsgs = outMsgs,
InMsgs = inMsgs,
NumSubs = numSubs,
Pending = pending,
Start = connectedAt == default ? DateTime.UtcNow : connectedAt,
LastActivity = lastActivity == default ? DateTime.UtcNow : lastActivity,
};
// -----------------------------------------------------------------------
// ConnzSorter.Parse tests
// -----------------------------------------------------------------------
[Fact]
public void Parse_ConnectionId_Default()
{
// Go reference: monitor_sort_opts.go — empty/unknown value defaults to CID sort
ConnzSorter.Parse(null).ShouldBe(ConnzSortOption.ConnectionId);
ConnzSorter.Parse("").ShouldBe(ConnzSortOption.ConnectionId);
ConnzSorter.Parse(" ").ShouldBe(ConnzSortOption.ConnectionId);
ConnzSorter.Parse("cid").ShouldBe(ConnzSortOption.ConnectionId);
}
[Fact]
public void Parse_BytesTo_Parsed()
{
// Go reference: monitor_sort_opts.go — "bytes_to" => ByBytesTo
ConnzSorter.Parse("bytes_to").ShouldBe(ConnzSortOption.BytesTo);
ConnzSorter.Parse("BYTES_TO").ShouldBe(ConnzSortOption.BytesTo);
}
[Fact]
public void Parse_MsgsFrom_Parsed()
{
// Go reference: monitor_sort_opts.go — "msgs_from" => ByMsgsFrom
ConnzSorter.Parse("msgs_from").ShouldBe(ConnzSortOption.MsgsFrom);
ConnzSorter.Parse("MSGS_FROM").ShouldBe(ConnzSortOption.MsgsFrom);
}
[Fact]
public void Parse_Start_Parsed()
{
// Go reference: monitor_sort_opts.go — "start" => ByStart
ConnzSorter.Parse("start").ShouldBe(ConnzSortOption.Start);
ConnzSorter.Parse("START").ShouldBe(ConnzSortOption.Start);
}
[Fact]
public void Parse_Unknown_ReturnsDefault()
{
// Go reference: monitor_sort_opts.go — unrecognised string falls back to CID
ConnzSorter.Parse("not_a_sort_key").ShouldBe(ConnzSortOption.ConnectionId);
ConnzSorter.Parse("xyz").ShouldBe(ConnzSortOption.ConnectionId);
ConnzSorter.Parse("RANDOM").ShouldBe(ConnzSortOption.ConnectionId);
}
// -----------------------------------------------------------------------
// ConnzSorter.Sort tests
// -----------------------------------------------------------------------
[Fact]
public void Sort_ByConnectionId_Ascending()
{
// Go reference: monitor_test.go TestConnzSortedByCid — CID ascending is default
var conns = new[]
{
MakeConn(cid: 3),
MakeConn(cid: 1),
MakeConn(cid: 2),
};
var result = ConnzSorter.Sort(conns, ConnzSortOption.ConnectionId);
result.Count.ShouldBe(3);
result[0].Cid.ShouldBe(1UL);
result[1].Cid.ShouldBe(2UL);
result[2].Cid.ShouldBe(3UL);
}
[Fact]
public void Sort_ByBytesTo_Descending()
{
// Go reference: monitor_test.go TestConnzSortedByBytesTo — OutBytes descending
var conns = new[]
{
MakeConn(cid: 1, outBytes: 100),
MakeConn(cid: 2, outBytes: 300),
MakeConn(cid: 3, outBytes: 200),
};
var result = ConnzSorter.Sort(conns, ConnzSortOption.BytesTo);
result.Count.ShouldBe(3);
result[0].OutBytes.ShouldBe(300);
result[1].OutBytes.ShouldBe(200);
result[2].OutBytes.ShouldBe(100);
}
[Fact]
public void Sort_ByStart_Ascending()
{
// Go reference: monitor_test.go TestConnzSortedByStart — ConnectedAt ascending
var t0 = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var t1 = t0.AddMinutes(1);
var t2 = t0.AddMinutes(2);
var conns = new[]
{
MakeConn(cid: 1, connectedAt: t2),
MakeConn(cid: 2, connectedAt: t0),
MakeConn(cid: 3, connectedAt: t1),
};
var result = ConnzSorter.Sort(conns, ConnzSortOption.Start);
result.Count.ShouldBe(3);
result[0].Start.ShouldBe(t0);
result[1].Start.ShouldBe(t1);
result[2].Start.ShouldBe(t2);
}
[Fact]
public void Sort_ByMsgsFrom_Descending()
{
// Go reference: monitor_test.go TestConnzSortedByMsgsFrom — InMsgs descending
var conns = new[]
{
MakeConn(cid: 1, inMsgs: 50),
MakeConn(cid: 2, inMsgs: 200),
MakeConn(cid: 3, inMsgs: 10),
};
var result = ConnzSorter.Sort(conns, ConnzSortOption.MsgsFrom);
result.Count.ShouldBe(3);
result[0].InMsgs.ShouldBe(200);
result[1].InMsgs.ShouldBe(50);
result[2].InMsgs.ShouldBe(10);
}
[Fact]
public void Sort_EmptyList_ReturnsEmpty()
{
// Sorting an empty input should not throw and should return an empty list.
var result = ConnzSorter.Sort([], ConnzSortOption.BytesTo);
result.ShouldBeEmpty();
}
}

View File

@@ -0,0 +1,77 @@
// Ported from golang/nats-server/server/monitor_test.go
// TestMonitorHealthzStatusOK — verify /healthz returns HTTP 200 with status "ok".
using System.Net;
using System.Net.Sockets;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server.TestUtilities;
namespace NATS.Server.Monitoring.Tests;
public class HealthzParityTests : IAsyncLifetime
{
private readonly NatsServer _server;
private readonly int _monitorPort;
private readonly CancellationTokenSource _cts = new();
private readonly HttpClient _http = new();
public HealthzParityTests()
{
_monitorPort = TestPortAllocator.GetFreePort();
_server = new NatsServer(
new NatsOptions { Port = 0, MonitorPort = _monitorPort },
NullLoggerFactory.Instance);
}
public async Task InitializeAsync()
{
_ = _server.StartAsync(_cts.Token);
await _server.WaitForReadyAsync();
for (var i = 0; i < 50; i++)
{
try
{
var probe = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/healthz");
if (probe.IsSuccessStatusCode) break;
}
catch (HttpRequestException) { }
await Task.Delay(50);
}
}
public async Task DisposeAsync()
{
_http.Dispose();
await _cts.CancelAsync();
_server.Dispose();
}
/// <summary>
/// Corresponds to Go TestMonitorHealthzStatusOK.
/// Verifies GET /healthz returns HTTP 200 OK, indicating the server is healthy.
/// </summary>
[Fact]
public async Task Healthz_returns_ok()
{
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/healthz");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
}
/// <summary>
/// Corresponds to Go TestMonitorHealthzStatusOK / checkHealthStatus.
/// Verifies the /healthz response body contains the "ok" status string,
/// matching the Go server's HealthStatus.Status = "ok" field.
/// </summary>
[Fact]
public async Task Healthz_returns_status_ok_json()
{
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/healthz");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var body = await response.Content.ReadAsStringAsync();
// The .NET monitoring server returns Results.Ok("ok") which serializes as the JSON string "ok".
// This corresponds to the Go server's HealthStatus.Status = "ok".
body.ShouldContain("ok");
}
}

View File

@@ -0,0 +1,820 @@
// Go: TestMonitorConnz server/monitor_test.go:367
// Go: TestMonitorConnzWithSubs server/monitor_test.go:442
// Go: TestMonitorConnzWithSubsDetail server/monitor_test.go:463
// Go: TestMonitorClosedConnzWithSubsDetail server/monitor_test.go:484
// Go: TestMonitorConnzRTT server/monitor_test.go:583
// Go: TestMonitorConnzLastActivity server/monitor_test.go:638
// Go: TestMonitorConnzWithOffsetAndLimit server/monitor_test.go:732
// Go: TestMonitorConnzDefaultSorted server/monitor_test.go:806
// Go: TestMonitorConnzSortedByCid server/monitor_test.go:827
// Go: TestMonitorConnzSortedByStart server/monitor_test.go:849
// Go: TestMonitorConnzSortedByBytesAndMsgs server/monitor_test.go:871
// Go: TestMonitorConnzSortedByPending server/monitor_test.go:925
// Go: TestMonitorConnzSortedBySubs server/monitor_test.go:950
// Go: TestMonitorConnzSortedByLast server/monitor_test.go:976
// Go: TestMonitorConnzSortedByUptime server/monitor_test.go:1007
// Go: TestMonitorConnzSortedByIdle server/monitor_test.go:1202
// Go: TestMonitorConnzSortedByStopOnOpen server/monitor_test.go:1074
// Go: TestMonitorConnzSortedByReason server/monitor_test.go:1141
// Go: TestMonitorConnzWithNamedClient server/monitor_test.go:1851
// Go: TestMonitorConnzWithStateForClosedConns server/monitor_test.go:1876
// Go: TestMonitorConcurrentMonitoring server/monitor_test.go:2148
// Go: TestMonitorConnzSortByRTT server/monitor_test.go:5979
using System.Net;
using System.Net.Http.Json;
using System.Net.Sockets;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server.Monitoring;
using NATS.Server.TestUtilities;
namespace NATS.Server.Monitoring.Tests.Monitoring;
/// <summary>
/// Tests covering /connz endpoint behavior, ported from the Go server's monitor_test.go.
/// </summary>
public class MonitorConnzTests : IAsyncLifetime
{
private readonly NatsServer _server;
private readonly int _natsPort;
private readonly int _monitorPort;
private readonly CancellationTokenSource _cts = new();
private readonly HttpClient _http = new();
public MonitorConnzTests()
{
_natsPort = TestPortAllocator.GetFreePort();
_monitorPort = TestPortAllocator.GetFreePort();
_server = new NatsServer(
new NatsOptions { Port = _natsPort, MonitorPort = _monitorPort },
NullLoggerFactory.Instance);
}
public async Task InitializeAsync()
{
_ = _server.StartAsync(_cts.Token);
await _server.WaitForReadyAsync();
for (var i = 0; i < 50; i++)
{
try
{
var probe = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/healthz");
if (probe.IsSuccessStatusCode) break;
}
catch (HttpRequestException) { }
await Task.Delay(50);
}
}
public async Task DisposeAsync()
{
_http.Dispose();
await _cts.CancelAsync();
_server.Dispose();
}
/// <summary>
/// Go: TestMonitorConnz (line 367).
/// Verifies /connz returns empty connections when no clients are connected.
/// </summary>
[Fact]
public async Task Connz_returns_empty_when_no_clients()
{
var connz = await _http.GetFromJsonAsync<Connz>($"http://127.0.0.1:{_monitorPort}/connz");
connz.ShouldNotBeNull();
connz.NumConns.ShouldBe(0);
connz.Total.ShouldBe(0);
connz.Conns.Length.ShouldBe(0);
}
/// <summary>
/// Go: TestMonitorConnz (line 367).
/// Verifies /connz lists active connections with populated identity fields.
/// </summary>
[Fact]
public async Task Connz_lists_active_connections_with_fields()
{
using var sock = await ConnectClientAsync("{\"name\":\"c1\",\"lang\":\"csharp\",\"version\":\"1.0\"}", "SUB foo 1\r\nPUB foo 5\r\nhello\r\n");
await Task.Delay(200);
var connz = await _http.GetFromJsonAsync<Connz>($"http://127.0.0.1:{_monitorPort}/connz");
connz.ShouldNotBeNull();
connz.NumConns.ShouldBe(1);
connz.Total.ShouldBe(1);
connz.Conns.Length.ShouldBe(1);
var ci = connz.Conns[0];
// Go: ci.IP == "127.0.0.1"
ci.Ip.ShouldBe("127.0.0.1");
ci.Port.ShouldBeGreaterThan(0);
ci.Cid.ShouldBeGreaterThan(0UL);
ci.Name.ShouldBe("c1");
ci.Lang.ShouldBe("csharp");
ci.Version.ShouldBe("1.0");
ci.Start.ShouldBeGreaterThan(DateTime.MinValue);
ci.LastActivity.ShouldBeGreaterThanOrEqualTo(ci.Start);
ci.Uptime.ShouldNotBeNullOrEmpty();
ci.Idle.ShouldNotBeNullOrEmpty();
}
/// <summary>
/// Go: TestMonitorConnz (line 367).
/// Verifies /connz default limit is 1024 and offset is 0.
/// </summary>
[Fact]
public async Task Connz_default_limit_and_offset()
{
var connz = await _http.GetFromJsonAsync<Connz>($"http://127.0.0.1:{_monitorPort}/connz");
connz.ShouldNotBeNull();
connz.Limit.ShouldBe(1024); // Go: DefaultConnListSize
connz.Offset.ShouldBe(0);
}
/// <summary>
/// Go: TestMonitorConnzWithSubs (line 442).
/// Verifies /connz?subs=1 includes subscriptions list.
/// </summary>
[Fact]
public async Task Connz_with_subs_includes_subscription_list()
{
using var sock = await ConnectClientAsync("{}", "SUB hello.foo 1\r\n");
await Task.Delay(200);
var connz = await _http.GetFromJsonAsync<Connz>($"http://127.0.0.1:{_monitorPort}/connz?subs=1");
connz.ShouldNotBeNull();
connz.Conns.Length.ShouldBeGreaterThanOrEqualTo(1);
var ci = connz.Conns[0];
// Go: len(ci.Subs) != 1 || ci.Subs[0] != "hello.foo"
ci.Subs.ShouldContain("hello.foo");
}
/// <summary>
/// Go: TestMonitorConnzWithSubsDetail (line 463).
/// Verifies /connz?subs=detail includes subscription detail objects.
/// </summary>
[Fact]
public async Task Connz_with_subs_detail_includes_subscription_detail()
{
using var sock = await ConnectClientAsync("{}", "SUB hello.foo 1\r\n");
await Task.Delay(200);
var connz = await _http.GetFromJsonAsync<Connz>($"http://127.0.0.1:{_monitorPort}/connz?subs=detail");
connz.ShouldNotBeNull();
connz.Conns.Length.ShouldBeGreaterThanOrEqualTo(1);
var ci = connz.Conns[0];
// Go: len(ci.SubsDetail) != 1 || ci.SubsDetail[0].Subject != "hello.foo"
ci.SubsDetail.Length.ShouldBeGreaterThanOrEqualTo(1);
ci.SubsDetail.ShouldContain(sd => sd.Subject == "hello.foo");
}
/// <summary>
/// Go: TestMonitorConnzWithNamedClient (line 1851).
/// Verifies /connz exposes client name set in CONNECT options.
/// </summary>
[Fact]
public async Task Connz_shows_named_client()
{
using var sock = await ConnectClientAsync("{\"name\":\"test-client\"}");
await Task.Delay(200);
var connz = await _http.GetFromJsonAsync<Connz>($"http://127.0.0.1:{_monitorPort}/connz");
connz.ShouldNotBeNull();
connz.Conns.Length.ShouldBe(1);
connz.Conns[0].Name.ShouldBe("test-client");
}
/// <summary>
/// Go: TestMonitorConnzWithOffsetAndLimit (line 732).
/// Verifies /connz pagination with offset and limit parameters.
/// </summary>
[Fact]
public async Task Connz_pagination_with_offset_and_limit()
{
var sockets = new List<Socket>();
try
{
for (var i = 0; i < 3; i++)
sockets.Add(await ConnectClientAsync("{}"));
await Task.Delay(200);
// offset=1, limit=1 should return 1 connection with total of 3
var connz = await _http.GetFromJsonAsync<Connz>($"http://127.0.0.1:{_monitorPort}/connz?offset=1&limit=1");
connz.ShouldNotBeNull();
connz.Limit.ShouldBe(1);
connz.Offset.ShouldBe(1);
connz.Conns.Length.ShouldBe(1);
connz.NumConns.ShouldBe(1);
connz.Total.ShouldBeGreaterThanOrEqualTo(3);
// offset past end should return 0
var connz2 = await _http.GetFromJsonAsync<Connz>($"http://127.0.0.1:{_monitorPort}/connz?offset=10&limit=1");
connz2.ShouldNotBeNull();
connz2.Conns.Length.ShouldBe(0);
connz2.NumConns.ShouldBe(0);
connz2.Total.ShouldBeGreaterThanOrEqualTo(3);
}
finally
{
foreach (var s in sockets) s.Dispose();
}
}
/// <summary>
/// Go: TestMonitorConnzDefaultSorted (line 806).
/// Verifies /connz defaults to ascending CID sort order.
/// </summary>
[Fact]
public async Task Connz_default_sorted_by_cid_ascending()
{
var sockets = new List<Socket>();
try
{
for (var i = 0; i < 4; i++)
sockets.Add(await ConnectClientAsync("{}"));
await Task.Delay(200);
var connz = await _http.GetFromJsonAsync<Connz>($"http://127.0.0.1:{_monitorPort}/connz");
connz.ShouldNotBeNull();
connz.Conns.Length.ShouldBeGreaterThanOrEqualTo(4);
// Go: Conns[0].Cid < Conns[1].Cid < Conns[2].Cid < Conns[3].Cid
for (var i = 1; i < connz.Conns.Length; i++)
connz.Conns[i].Cid.ShouldBeGreaterThan(connz.Conns[i - 1].Cid);
}
finally
{
foreach (var s in sockets) s.Dispose();
}
}
/// <summary>
/// Go: TestMonitorConnzSortedByCid (line 827).
/// Verifies /connz?sort=cid returns connections sorted by CID.
/// </summary>
[Fact]
public async Task Connz_sort_by_cid()
{
var sockets = new List<Socket>();
try
{
for (var i = 0; i < 4; i++)
sockets.Add(await ConnectClientAsync("{}"));
await Task.Delay(200);
var connz = await _http.GetFromJsonAsync<Connz>($"http://127.0.0.1:{_monitorPort}/connz?sort=cid");
connz.ShouldNotBeNull();
for (var i = 1; i < connz.Conns.Length; i++)
connz.Conns[i].Cid.ShouldBeGreaterThan(connz.Conns[i - 1].Cid);
}
finally
{
foreach (var s in sockets) s.Dispose();
}
}
/// <summary>
/// Go: TestMonitorConnzSortedByStart (line 849).
/// Verifies /connz?sort=start returns connections sorted by start time.
/// </summary>
[Fact]
public async Task Connz_sort_by_start()
{
var sockets = new List<Socket>();
try
{
for (var i = 0; i < 3; i++)
{
sockets.Add(await ConnectClientAsync("{}"));
await Task.Delay(10);
}
await Task.Delay(200);
var connz = await _http.GetFromJsonAsync<Connz>($"http://127.0.0.1:{_monitorPort}/connz?sort=start");
connz.ShouldNotBeNull();
for (var i = 1; i < connz.Conns.Length; i++)
connz.Conns[i].Start.ShouldBeGreaterThanOrEqualTo(connz.Conns[i - 1].Start);
}
finally
{
foreach (var s in sockets) s.Dispose();
}
}
/// <summary>
/// Go: TestMonitorConnzSortedByBytesAndMsgs (line 871).
/// Verifies /connz?sort=bytes_to returns connections sorted by out_bytes descending.
/// </summary>
[Fact]
public async Task Connz_sort_by_bytes_to()
{
var sockets = new List<Socket>();
try
{
// Subscriber first
sockets.Add(await ConnectClientAsync("{}", "SUB foo 1\r\n"));
// High-traffic publisher
var pub = await ConnectClientAsync("{}");
sockets.Add(pub);
using var ns = new NetworkStream(pub);
for (var i = 0; i < 50; i++)
await ns.WriteAsync("PUB foo 5\r\nhello\r\n"u8.ToArray());
await ns.FlushAsync();
// Low-traffic client
sockets.Add(await ConnectClientAsync("{}"));
await Task.Delay(300);
var connz = await _http.GetFromJsonAsync<Connz>($"http://127.0.0.1:{_monitorPort}/connz?sort=bytes_to");
connz.ShouldNotBeNull();
connz.Conns.Length.ShouldBeGreaterThanOrEqualTo(2);
// First entry should have >= out_bytes than second
connz.Conns[0].OutBytes.ShouldBeGreaterThanOrEqualTo(connz.Conns[1].OutBytes);
}
finally
{
foreach (var s in sockets) s.Dispose();
}
}
/// <summary>
/// Go: TestMonitorConnzSortedByBytesAndMsgs (line 871).
/// Verifies /connz?sort=msgs_to returns connections sorted by out_msgs descending.
/// </summary>
[Fact]
public async Task Connz_sort_by_msgs_to()
{
var sockets = new List<Socket>();
try
{
sockets.Add(await ConnectClientAsync("{}", "SUB foo 1\r\n"));
var pub = await ConnectClientAsync("{}");
sockets.Add(pub);
using var ns = new NetworkStream(pub);
for (var i = 0; i < 50; i++)
await ns.WriteAsync("PUB foo 5\r\nhello\r\n"u8.ToArray());
await ns.FlushAsync();
sockets.Add(await ConnectClientAsync("{}"));
await Task.Delay(300);
var connz = await _http.GetFromJsonAsync<Connz>($"http://127.0.0.1:{_monitorPort}/connz?sort=msgs_to");
connz.ShouldNotBeNull();
connz.Conns.Length.ShouldBeGreaterThanOrEqualTo(2);
connz.Conns[0].OutMsgs.ShouldBeGreaterThanOrEqualTo(connz.Conns[1].OutMsgs);
}
finally
{
foreach (var s in sockets) s.Dispose();
}
}
/// <summary>
/// Go: TestMonitorConnzSortedByBytesAndMsgs (line 871).
/// Verifies /connz?sort=msgs_from returns connections sorted by in_msgs descending.
/// </summary>
[Fact]
public async Task Connz_sort_by_msgs_from()
{
var sockets = new List<Socket>();
try
{
var pub = await ConnectClientAsync("{}");
sockets.Add(pub);
using var ns = new NetworkStream(pub);
for (var i = 0; i < 50; i++)
await ns.WriteAsync("PUB foo 5\r\nhello\r\n"u8.ToArray());
await ns.FlushAsync();
sockets.Add(await ConnectClientAsync("{}"));
await Task.Delay(300);
var connz = await _http.GetFromJsonAsync<Connz>($"http://127.0.0.1:{_monitorPort}/connz?sort=msgs_from");
connz.ShouldNotBeNull();
connz.Conns.Length.ShouldBeGreaterThanOrEqualTo(2);
connz.Conns[0].InMsgs.ShouldBeGreaterThanOrEqualTo(connz.Conns[1].InMsgs);
}
finally
{
foreach (var s in sockets) s.Dispose();
}
}
/// <summary>
/// Go: TestMonitorConnzSortedBySubs (line 950).
/// Verifies /connz?sort=subs returns connections sorted by subscription count descending.
/// </summary>
[Fact]
public async Task Connz_sort_by_subs()
{
var sockets = new List<Socket>();
try
{
// Client with many subs
sockets.Add(await ConnectClientAsync("{}", "SUB a 1\r\nSUB b 2\r\nSUB c 3\r\n"));
// Client with no subs
sockets.Add(await ConnectClientAsync("{}"));
await Task.Delay(200);
var connz = await _http.GetFromJsonAsync<Connz>($"http://127.0.0.1:{_monitorPort}/connz?sort=subs");
connz.ShouldNotBeNull();
connz.Conns.Length.ShouldBeGreaterThanOrEqualTo(2);
connz.Conns[0].NumSubs.ShouldBeGreaterThanOrEqualTo(connz.Conns[1].NumSubs);
}
finally
{
foreach (var s in sockets) s.Dispose();
}
}
/// <summary>
/// Go: TestMonitorConnzSortedByLast (line 976).
/// Verifies /connz?sort=last returns connections sorted by last_activity descending.
/// </summary>
[Fact]
public async Task Connz_sort_by_last_activity()
{
var sockets = new List<Socket>();
try
{
// First client connects and does something early
sockets.Add(await ConnectClientAsync("{}"));
await Task.Delay(50);
// Second client connects later and does activity
sockets.Add(await ConnectClientAsync("{}", "PUB foo 2\r\nhi\r\n"));
await Task.Delay(200);
var connz = await _http.GetFromJsonAsync<Connz>($"http://127.0.0.1:{_monitorPort}/connz?sort=last");
connz.ShouldNotBeNull();
connz.Conns.Length.ShouldBeGreaterThanOrEqualTo(2);
connz.Conns[0].LastActivity.ShouldBeGreaterThanOrEqualTo(connz.Conns[1].LastActivity);
}
finally
{
foreach (var s in sockets) s.Dispose();
}
}
/// <summary>
/// Go: TestMonitorConnzSortedByUptime (line 1007).
/// Verifies /connz?sort=uptime returns connections sorted by uptime descending.
/// </summary>
[Fact]
public async Task Connz_sort_by_uptime()
{
var sockets = new List<Socket>();
try
{
// First client has longer uptime
sockets.Add(await ConnectClientAsync("{}"));
await Task.Delay(100);
// Second client has shorter uptime
sockets.Add(await ConnectClientAsync("{}"));
await Task.Delay(200);
var connz = await _http.GetFromJsonAsync<Connz>($"http://127.0.0.1:{_monitorPort}/connz?sort=uptime");
connz.ShouldNotBeNull();
connz.Conns.Length.ShouldBeGreaterThanOrEqualTo(2);
// Descending by uptime means first entry started earlier
connz.Conns[0].Start.ShouldBeLessThanOrEqualTo(connz.Conns[1].Start);
}
finally
{
foreach (var s in sockets) s.Dispose();
}
}
/// <summary>
/// Go: TestMonitorConnzSortedByIdle (line 1202).
/// Verifies /connz?sort=idle returns connections sorted by idle time descending.
/// </summary>
[Fact]
public async Task Connz_sort_by_idle()
{
var sockets = new List<Socket>();
try
{
// First client: older activity (more idle)
sockets.Add(await ConnectClientAsync("{}"));
await Task.Delay(200);
// Second client: recent activity (less idle)
sockets.Add(await ConnectClientAsync("{}", "PUB foo 2\r\nhi\r\n"));
await Task.Delay(200);
var connz = await _http.GetFromJsonAsync<Connz>($"http://127.0.0.1:{_monitorPort}/connz?sort=idle");
connz.ShouldNotBeNull();
connz.Conns.Length.ShouldBeGreaterThanOrEqualTo(2);
// Idle descending: first entry has older last activity
connz.Conns[0].LastActivity.ShouldBeLessThanOrEqualTo(connz.Conns[1].LastActivity);
}
finally
{
foreach (var s in sockets) s.Dispose();
}
}
/// <summary>
/// Go: TestMonitorConnzWithStateForClosedConns (line 1876).
/// Verifies /connz?state=closed returns recently disconnected clients.
/// </summary>
[Fact]
public async Task Connz_state_closed_returns_disconnected_clients()
{
var sock = await ConnectClientAsync("{\"name\":\"closing-client\"}");
await Task.Delay(200);
sock.Shutdown(SocketShutdown.Both);
sock.Dispose();
await Task.Delay(500);
var connz = await _http.GetFromJsonAsync<Connz>($"http://127.0.0.1:{_monitorPort}/connz?state=closed");
connz.ShouldNotBeNull();
connz.Conns.ShouldContain(c => c.Name == "closing-client");
var closed = connz.Conns.First(c => c.Name == "closing-client");
closed.Stop.ShouldNotBeNull();
closed.Reason.ShouldNotBeNullOrEmpty();
}
/// <summary>
/// Go: TestMonitorConnzSortedByStopOnOpen (line 1074).
/// Verifies /connz?sort=stop&state=open falls back to CID sort without error.
/// </summary>
[Fact]
public async Task Connz_sort_by_stop_with_open_state_falls_back_to_cid()
{
using var sock = await ConnectClientAsync("{}");
await Task.Delay(200);
// Go: sort by stop on open state should fallback
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/connz?sort=stop&state=open");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
}
/// <summary>
/// Go: TestMonitorConnzSortedByReason (line 1141).
/// Verifies /connz?sort=reason&state=closed sorts by close reason.
/// </summary>
[Fact]
public async Task Connz_sort_by_reason_on_closed()
{
var sock = await ConnectClientAsync("{}");
await Task.Delay(100);
sock.Shutdown(SocketShutdown.Both);
sock.Dispose();
await Task.Delay(500);
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/connz?sort=reason&state=closed");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
}
/// <summary>
/// Go: TestMonitorConnzSortedByReasonOnOpen (line 1180).
/// Verifies /connz?sort=reason&state=open falls back to CID sort without error.
/// </summary>
[Fact]
public async Task Connz_sort_by_reason_with_open_state_falls_back()
{
using var sock = await ConnectClientAsync("{}");
await Task.Delay(200);
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/connz?sort=reason&state=open");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
}
/// <summary>
/// Go: TestMonitorConnzSortByRTT (line 5979).
/// Verifies /connz?sort=rtt does not error.
/// </summary>
[Fact]
public async Task Connz_sort_by_rtt_succeeds()
{
using var sock = await ConnectClientAsync("{}");
await Task.Delay(200);
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/connz?sort=rtt");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
}
/// <summary>
/// Go: TestMonitorConnz (line 367).
/// Verifies /connz per-connection message stats are populated after pub/sub.
/// </summary>
[Fact]
public async Task Connz_per_connection_message_stats()
{
using var sock = await ConnectClientAsync("{}", "SUB foo 1\r\nPUB foo 5\r\nhello\r\n");
await Task.Delay(200);
var connz = await _http.GetFromJsonAsync<Connz>($"http://127.0.0.1:{_monitorPort}/connz");
connz.ShouldNotBeNull();
connz.Conns.Length.ShouldBe(1);
var ci = connz.Conns[0];
// Go: ci.InMsgs == 1, ci.InBytes == 5
ci.InMsgs.ShouldBeGreaterThanOrEqualTo(1L);
ci.InBytes.ShouldBeGreaterThanOrEqualTo(5L);
}
/// <summary>
/// Go: TestMonitorConnzRTT (line 583).
/// Verifies /connz includes RTT field for connected clients.
/// </summary>
[Fact]
public async Task Connz_includes_rtt_field()
{
using var sock = await ConnectClientAsync("{}");
// Send a PING to trigger RTT measurement
using var ns = new NetworkStream(sock);
await ns.WriteAsync("PING\r\n"u8.ToArray());
await Task.Delay(200);
var connz = await _http.GetFromJsonAsync<Connz>($"http://127.0.0.1:{_monitorPort}/connz");
connz.ShouldNotBeNull();
connz.Conns.Length.ShouldBeGreaterThanOrEqualTo(1);
// RTT may or may not be populated depending on implementation, but field must exist
connz.Conns[0].Rtt.ShouldNotBeNull();
}
/// <summary>
/// Go: TestMonitorConnzLastActivity (line 638).
/// Verifies /connz last_activity is updated after message activity.
/// </summary>
[Fact]
public async Task Connz_last_activity_updates_after_message()
{
using var sock = await ConnectClientAsync("{}");
await Task.Delay(100);
// Record initial last activity
var connz1 = await _http.GetFromJsonAsync<Connz>($"http://127.0.0.1:{_monitorPort}/connz");
var initial = connz1!.Conns[0].LastActivity;
// Do more activity
using var ns = new NetworkStream(sock);
await ns.WriteAsync("PUB foo 5\r\nhello\r\n"u8.ToArray());
await ns.FlushAsync();
await Task.Delay(200);
var connz2 = await _http.GetFromJsonAsync<Connz>($"http://127.0.0.1:{_monitorPort}/connz");
var updated = connz2!.Conns[0].LastActivity;
// Activity should have updated
updated.ShouldBeGreaterThanOrEqualTo(initial);
}
/// <summary>
/// Go: TestMonitorConcurrentMonitoring (line 2148).
/// Verifies concurrent /connz requests do not cause errors.
/// </summary>
[Fact]
public async Task Connz_handles_concurrent_requests()
{
using var sock = await ConnectClientAsync("{}");
await Task.Delay(200);
var tasks = Enumerable.Range(0, 10).Select(async _ =>
{
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/connz");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
});
await Task.WhenAll(tasks);
}
/// <summary>
/// Go: TestMonitorConnz (line 367).
/// Verifies /connz JSON uses correct Go-compatible field names.
/// </summary>
[Fact]
public async Task Connz_json_uses_go_field_names()
{
using var sock = await ConnectClientAsync("{}");
await Task.Delay(200);
var body = await _http.GetStringAsync($"http://127.0.0.1:{_monitorPort}/connz");
body.ShouldContain("\"server_id\"");
body.ShouldContain("\"num_connections\"");
body.ShouldContain("\"connections\"");
}
/// <summary>
/// Go: TestMonitorConnzWithStateForClosedConns (line 1876).
/// Verifies /connz?state=all returns both open and closed connections.
/// </summary>
[Fact]
public async Task Connz_state_all_returns_both_open_and_closed()
{
// Connect and disconnect one client
var sock = await ConnectClientAsync("{\"name\":\"will-close\"}");
await Task.Delay(100);
sock.Shutdown(SocketShutdown.Both);
sock.Dispose();
await Task.Delay(300);
// Connect another client that stays open
using var sock2 = await ConnectClientAsync("{\"name\":\"stays-open\"}");
await Task.Delay(200);
var connz = await _http.GetFromJsonAsync<Connz>($"http://127.0.0.1:{_monitorPort}/connz?state=all");
connz.ShouldNotBeNull();
connz.Total.ShouldBeGreaterThanOrEqualTo(2);
}
/// <summary>
/// Go: TestMonitorConnz (line 367).
/// Verifies /connz server_id matches the server's ID.
/// </summary>
[Fact]
public async Task Connz_server_id_matches_server()
{
var varz = await _http.GetFromJsonAsync<Varz>($"http://127.0.0.1:{_monitorPort}/varz");
var connz = await _http.GetFromJsonAsync<Connz>($"http://127.0.0.1:{_monitorPort}/connz");
connz!.Id.ShouldBe(varz!.Id);
}
/// <summary>
/// Go: TestMonitorConnzSortedByPending (line 925).
/// Verifies /connz?sort=pending returns connections sorted by pending bytes descending.
/// </summary>
[Fact]
public async Task Connz_sort_by_pending()
{
var sockets = new List<Socket>();
try
{
sockets.Add(await ConnectClientAsync("{}"));
sockets.Add(await ConnectClientAsync("{}"));
await Task.Delay(200);
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/connz?sort=pending");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
}
finally
{
foreach (var s in sockets) s.Dispose();
}
}
/// <summary>
/// Go: TestMonitorConnzSortedByBytesAndMsgs (line 871).
/// Verifies /connz?sort=bytes_from returns connections sorted by in_bytes descending.
/// </summary>
[Fact]
public async Task Connz_sort_by_bytes_from()
{
var sockets = new List<Socket>();
try
{
// High-traffic publisher
var pub = await ConnectClientAsync("{}");
sockets.Add(pub);
using var ns = new NetworkStream(pub);
for (var i = 0; i < 50; i++)
await ns.WriteAsync("PUB foo 5\r\nhello\r\n"u8.ToArray());
await ns.FlushAsync();
// Low-traffic client
sockets.Add(await ConnectClientAsync("{}"));
await Task.Delay(300);
var connz = await _http.GetFromJsonAsync<Connz>($"http://127.0.0.1:{_monitorPort}/connz?sort=bytes_from");
connz.ShouldNotBeNull();
connz.Conns.Length.ShouldBeGreaterThanOrEqualTo(2);
connz.Conns[0].InBytes.ShouldBeGreaterThanOrEqualTo(connz.Conns[1].InBytes);
}
finally
{
foreach (var s in sockets) s.Dispose();
}
}
/// <summary>
/// Helper to connect a raw TCP client to the NATS server, send CONNECT and optional commands,
/// and return the socket. The caller is responsible for disposing the socket.
/// </summary>
private async Task<Socket> ConnectClientAsync(string connectJson, string? extraCommands = null)
{
var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _natsPort));
var buf = new byte[4096];
_ = await sock.ReceiveAsync(buf, SocketFlags.None); // consume INFO
var cmd = $"CONNECT {connectJson}\r\n";
if (extraCommands is not null)
cmd += extraCommands;
await sock.SendAsync(System.Text.Encoding.ASCII.GetBytes(cmd), SocketFlags.None);
return sock;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,263 @@
// Go: TestMonitorConnzWithRoutes server/monitor_test.go:1405
// Go: TestMonitorRoutezRace server/monitor_test.go:2210
// Go: TestMonitorRoutezRTT server/monitor_test.go:3919
// Go: TestMonitorRoutezPoolSize server/monitor_test.go:5705
// Go: TestMonitorClusterEmptyWhenNotDefined server/monitor_test.go:2456
using System.Net;
using System.Net.Http.Json;
using System.Net.Sockets;
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server.Configuration;
using NATS.Server.Monitoring;
using NATS.Server.TestUtilities;
namespace NATS.Server.Monitoring.Tests.Monitoring;
/// <summary>
/// Tests covering /routez endpoint behavior, ported from the Go server's monitor_test.go.
/// </summary>
public class MonitorRoutezTests
{
/// <summary>
/// Go: TestMonitorConnzWithRoutes (line 1405).
/// Verifies that /routez returns valid JSON with routes and num_routes fields.
/// </summary>
[Fact]
public async Task Routez_returns_routes_and_num_routes()
{
await using var fx = await RoutezFixture.StartAsync();
var body = await fx.GetStringAsync("/routez");
body.ShouldContain("routes");
body.ShouldContain("num_routes");
}
/// <summary>
/// Go: TestMonitorConnzWithRoutes (line 1405).
/// Verifies /routez num_routes is 0 when no cluster routes are configured.
/// </summary>
[Fact]
public async Task Routez_num_routes_is_zero_without_cluster()
{
await using var fx = await RoutezFixture.StartAsync();
var doc = await fx.GetJsonDocumentAsync("/routez");
doc.RootElement.GetProperty("num_routes").GetInt32().ShouldBe(0);
}
/// <summary>
/// Go: TestMonitorConnzWithRoutes (line 1405).
/// Verifies /connz does not include route connections (they appear under /routez only).
/// </summary>
[Fact]
public async Task Connz_does_not_include_route_connections()
{
await using var fx = await RoutezFixture.StartAsync();
var connz = await fx.GetFromJsonAsync<Connz>("/connz");
connz.ShouldNotBeNull();
// Without any clients, connz should be empty
connz.NumConns.ShouldBe(0);
}
/// <summary>
/// Go: TestMonitorRoutezRace (line 2210).
/// Verifies concurrent /routez requests do not cause errors or data corruption.
/// </summary>
[Fact]
public async Task Routez_handles_concurrent_requests()
{
await using var fx = await RoutezFixture.StartAsync();
var tasks = Enumerable.Range(0, 10).Select(async _ =>
{
var response = await fx.GetAsync("/routez");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
});
await Task.WhenAll(tasks);
}
/// <summary>
/// Go: TestMonitorClusterEmptyWhenNotDefined (line 2456).
/// Verifies /varz cluster section has empty name when no cluster is configured.
/// </summary>
[Fact]
public async Task Varz_cluster_empty_when_not_defined()
{
await using var fx = await RoutezFixture.StartAsync();
var varz = await fx.GetFromJsonAsync<Varz>("/varz");
varz.ShouldNotBeNull();
varz.Cluster.ShouldNotBeNull();
varz.Cluster.Name.ShouldBe("");
}
/// <summary>
/// Go: TestMonitorConnzWithRoutes (line 1405).
/// Verifies /routez JSON field naming matches Go server format.
/// </summary>
[Fact]
public async Task Routez_json_uses_expected_field_names()
{
await using var fx = await RoutezFixture.StartAsync();
var body = await fx.GetStringAsync("/routez");
body.ShouldContain("\"routes\"");
body.ShouldContain("\"num_routes\"");
}
/// <summary>
/// Go: TestMonitorCluster (line 2724).
/// Verifies /varz includes cluster section even when cluster is enabled.
/// Note: The .NET server currently initializes the cluster section with defaults;
/// the Go server populates it with cluster config. This test verifies the section exists.
/// </summary>
[Fact]
public async Task Varz_includes_cluster_section_when_cluster_enabled()
{
await using var fx = await RoutezFixture.StartWithClusterAsync();
var varz = await fx.GetFromJsonAsync<Varz>("/varz");
varz.ShouldNotBeNull();
varz.Cluster.ShouldNotBeNull();
}
/// <summary>
/// Go: TestMonitorConnzWithRoutes (line 1405).
/// Verifies /routez response includes routes field even when num_routes is 0.
/// </summary>
[Fact]
public async Task Routez_includes_routes_field_even_when_empty()
{
await using var fx = await RoutezFixture.StartAsync();
var doc = await fx.GetJsonDocumentAsync("/routez");
doc.RootElement.TryGetProperty("routes", out _).ShouldBeTrue();
}
/// <summary>
/// Go: TestMonitorConnzWithRoutes (line 1405).
/// Verifies /routez returns HTTP 200 OK.
/// </summary>
[Fact]
public async Task Routez_returns_http_200()
{
await using var fx = await RoutezFixture.StartAsync();
var response = await fx.GetAsync("/routez");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
}
/// <summary>
/// Go: TestMonitorCluster (line 2724).
/// Verifies /routez endpoint is accessible when cluster is configured.
/// </summary>
[Fact]
public async Task Routez_accessible_with_cluster_config()
{
await using var fx = await RoutezFixture.StartWithClusterAsync();
var response = await fx.GetAsync("/routez");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var body = await response.Content.ReadAsStringAsync();
body.ShouldContain("routes");
}
}
internal sealed class RoutezFixture : IAsyncDisposable
{
private readonly NatsServer _server;
private readonly CancellationTokenSource _cts;
private readonly HttpClient _http;
private readonly int _monitorPort;
private RoutezFixture(NatsServer server, CancellationTokenSource cts, HttpClient http, int monitorPort)
{
_server = server;
_cts = cts;
_http = http;
_monitorPort = monitorPort;
}
public static async Task<RoutezFixture> StartAsync()
{
var monitorPort = TestPortAllocator.GetFreePort();
var options = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
MonitorPort = monitorPort,
};
return await CreateAndStartAsync(options, monitorPort);
}
public static async Task<RoutezFixture> StartWithClusterAsync()
{
var monitorPort = TestPortAllocator.GetFreePort();
var options = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
MonitorPort = monitorPort,
Cluster = new ClusterOptions
{
Host = "127.0.0.1",
Port = 0,
Name = "test-cluster",
},
};
return await CreateAndStartAsync(options, monitorPort);
}
private static async Task<RoutezFixture> CreateAndStartAsync(NatsOptions options, int monitorPort)
{
var server = new NatsServer(options, NullLoggerFactory.Instance);
var cts = new CancellationTokenSource();
_ = server.StartAsync(cts.Token);
await server.WaitForReadyAsync();
var http = new HttpClient();
for (var i = 0; i < 50; i++)
{
try
{
var response = await http.GetAsync($"http://127.0.0.1:{monitorPort}/healthz");
if (response.IsSuccessStatusCode) break;
}
catch { }
await Task.Delay(50);
}
return new RoutezFixture(server, cts, http, monitorPort);
}
public Task<string> GetStringAsync(string path)
=> _http.GetStringAsync($"http://127.0.0.1:{_monitorPort}{path}");
public Task<HttpResponseMessage> GetAsync(string path)
=> _http.GetAsync($"http://127.0.0.1:{_monitorPort}{path}");
public Task<T?> GetFromJsonAsync<T>(string path)
=> _http.GetFromJsonAsync<T>($"http://127.0.0.1:{_monitorPort}{path}");
public async Task<JsonDocument> GetJsonDocumentAsync(string path)
{
var body = await GetStringAsync(path);
return JsonDocument.Parse(body);
}
public async ValueTask DisposeAsync()
{
_http.Dispose();
await _cts.CancelAsync();
_server.Dispose();
_cts.Dispose();
}
}

View File

@@ -0,0 +1,350 @@
// Go: TestMonitorStacksz server/monitor_test.go:2135
// Go: TestMonitorConcurrentMonitoring server/monitor_test.go:2148
// Go: TestMonitorHandleRoot server/monitor_test.go:1819
// Go: TestMonitorHTTPBasePath server/monitor_test.go:220
// Go: TestMonitorAccountz server/monitor_test.go:4300
// Go: TestMonitorAccountStatz server/monitor_test.go:4330
using System.Net;
using System.Net.Http.Json;
using System.Net.Sockets;
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server.Monitoring;
using NATS.Server.TestUtilities;
namespace NATS.Server.Monitoring.Tests.Monitoring;
/// <summary>
/// Tests covering miscellaneous monitoring endpoints: root, accountz, accstatz,
/// gatewayz, leafz, and concurrent monitoring safety.
/// Ported from the Go server's monitor_test.go.
/// </summary>
public class MonitorStackszTests : IAsyncLifetime
{
private readonly NatsServer _server;
private readonly int _natsPort;
private readonly int _monitorPort;
private readonly CancellationTokenSource _cts = new();
private readonly HttpClient _http = new();
public MonitorStackszTests()
{
_natsPort = TestPortAllocator.GetFreePort();
_monitorPort = TestPortAllocator.GetFreePort();
_server = new NatsServer(
new NatsOptions { Port = _natsPort, MonitorPort = _monitorPort },
NullLoggerFactory.Instance);
}
public async Task InitializeAsync()
{
_ = _server.StartAsync(_cts.Token);
await _server.WaitForReadyAsync();
for (var i = 0; i < 50; i++)
{
try
{
var probe = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/healthz");
if (probe.IsSuccessStatusCode) break;
}
catch (HttpRequestException) { }
await Task.Delay(50);
}
}
public async Task DisposeAsync()
{
_http.Dispose();
await _cts.CancelAsync();
_server.Dispose();
}
/// <summary>
/// Go: TestMonitorHandleRoot (line 1819).
/// Verifies GET / returns HTTP 200 with endpoint listing.
/// </summary>
[Fact]
public async Task Root_returns_endpoint_listing()
{
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var body = await response.Content.ReadAsStringAsync();
body.ShouldContain("varz");
body.ShouldContain("connz");
body.ShouldContain("routez");
body.ShouldContain("healthz");
}
/// <summary>
/// Go: TestMonitorHandleRoot (line 1819).
/// Verifies GET / response includes subsz endpoint.
/// </summary>
[Fact]
public async Task Root_includes_subz_endpoint()
{
var body = await _http.GetStringAsync($"http://127.0.0.1:{_monitorPort}/");
body.ShouldContain("subz");
}
/// <summary>
/// Go: TestMonitorAccountz (line 4300).
/// Verifies /accountz returns valid JSON with accounts list.
/// </summary>
[Fact]
public async Task Accountz_returns_accounts_list()
{
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/accountz");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var body = await response.Content.ReadAsStringAsync();
body.ShouldContain("accounts");
body.ShouldContain("num_accounts");
}
/// <summary>
/// Go: TestMonitorAccountz (line 4300).
/// Verifies /accountz num_accounts is at least 1 (global account).
/// </summary>
[Fact]
public async Task Accountz_num_accounts_at_least_one()
{
var doc = JsonDocument.Parse(await _http.GetStringAsync($"http://127.0.0.1:{_monitorPort}/accountz"));
doc.RootElement.GetProperty("num_accounts").GetInt32().ShouldBeGreaterThanOrEqualTo(1);
}
/// <summary>
/// Go: TestMonitorAccountStatz (line 4330).
/// Verifies /accstatz returns aggregate account statistics.
/// </summary>
[Fact]
public async Task Accstatz_returns_aggregate_stats()
{
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/accstatz");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var body = await response.Content.ReadAsStringAsync();
body.ShouldContain("total_accounts");
body.ShouldContain("total_connections");
body.ShouldContain("total_subscriptions");
}
/// <summary>
/// Go: TestMonitorAccountStatz (line 4330).
/// Verifies /accstatz total_accounts is at least 1.
/// </summary>
[Fact]
public async Task Accstatz_total_accounts_at_least_one()
{
var doc = JsonDocument.Parse(await _http.GetStringAsync($"http://127.0.0.1:{_monitorPort}/accstatz"));
doc.RootElement.GetProperty("total_accounts").GetInt32().ShouldBeGreaterThanOrEqualTo(1);
}
/// <summary>
/// Go: TestMonitorGateway (line 2880).
/// Verifies /gatewayz returns valid JSON.
/// </summary>
[Fact]
public async Task Gatewayz_returns_valid_json()
{
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/gatewayz");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var body = await response.Content.ReadAsStringAsync();
body.ShouldContain("gateways");
}
/// <summary>
/// Go: TestMonitorLeafNode (line 3112).
/// Verifies /leafz returns valid JSON.
/// </summary>
[Fact]
public async Task Leafz_returns_valid_json()
{
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/leafz");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var body = await response.Content.ReadAsStringAsync();
body.ShouldContain("leafs");
}
/// <summary>
/// Go: TestMonitorConcurrentMonitoring (line 2148).
/// Verifies concurrent requests across multiple endpoint types do not fail.
/// </summary>
[Fact]
public async Task Concurrent_requests_across_endpoints_succeed()
{
var endpoints = new[] { "varz", "varz", "connz", "connz", "subz", "subz", "routez", "routez" };
var tasks = endpoints.Select(async endpoint =>
{
for (var i = 0; i < 10; i++)
{
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/{endpoint}");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
}
});
await Task.WhenAll(tasks);
}
/// <summary>
/// Go: TestMonitorConcurrentMonitoring (line 2148).
/// Verifies concurrent /healthz requests do not fail.
/// </summary>
[Fact]
public async Task Concurrent_healthz_requests_succeed()
{
var tasks = Enumerable.Range(0, 20).Select(async _ =>
{
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/healthz");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
});
await Task.WhenAll(tasks);
}
/// <summary>
/// Go: TestMonitorHttpStatsNoUpdatedWhenUsingServerFuncs (line 2435).
/// Verifies /varz http_req_stats keys include all endpoints that were accessed.
/// </summary>
[Fact]
public async Task Http_req_stats_tracks_accessed_endpoints()
{
// Access multiple endpoints
await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/connz");
await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/subz");
await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/routez");
await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/varz");
var varz = await _http.GetFromJsonAsync<Varz>($"http://127.0.0.1:{_monitorPort}/varz");
varz.ShouldNotBeNull();
varz.HttpReqStats.ShouldContainKey("/connz");
varz.HttpReqStats.ShouldContainKey("/subz");
varz.HttpReqStats.ShouldContainKey("/routez");
varz.HttpReqStats.ShouldContainKey("/varz");
}
/// <summary>
/// Go: TestMonitorHandleRoot (line 1819).
/// Verifies GET / includes jsz endpoint in listing.
/// </summary>
[Fact]
public async Task Root_includes_jsz_endpoint()
{
var body = await _http.GetStringAsync($"http://127.0.0.1:{_monitorPort}/");
body.ShouldContain("jsz");
}
/// <summary>
/// Go: TestMonitorHandleRoot (line 1819).
/// Verifies GET / includes accountz endpoint in listing.
/// </summary>
[Fact]
public async Task Root_includes_accountz_endpoint()
{
var body = await _http.GetStringAsync($"http://127.0.0.1:{_monitorPort}/");
body.ShouldContain("accountz");
}
/// <summary>
/// Go: TestMonitorServerIDs (line 2410).
/// Verifies multiple monitoring endpoints return the same server_id.
/// </summary>
[Fact]
public async Task All_endpoints_return_consistent_server_id()
{
var varz = await _http.GetFromJsonAsync<Varz>($"http://127.0.0.1:{_monitorPort}/varz");
var connz = await _http.GetFromJsonAsync<Connz>($"http://127.0.0.1:{_monitorPort}/connz");
var subsz = await _http.GetFromJsonAsync<Subsz>($"http://127.0.0.1:{_monitorPort}/subz");
varz.ShouldNotBeNull();
connz.ShouldNotBeNull();
subsz.ShouldNotBeNull();
var serverId = varz.Id;
serverId.ShouldNotBeNullOrEmpty();
connz.Id.ShouldBe(serverId);
subsz.Id.ShouldBe(serverId);
}
/// <summary>
/// Go: TestMonitorAccountStatz (line 4330).
/// Verifies /accstatz total_connections updates after a client connects.
/// </summary>
[Fact]
public async Task Accstatz_total_connections_updates_after_connect()
{
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _natsPort));
var buf = new byte[4096];
_ = await sock.ReceiveAsync(buf, SocketFlags.None);
await sock.SendAsync("CONNECT {}\r\n"u8.ToArray(), SocketFlags.None);
await Task.Delay(200);
var doc = JsonDocument.Parse(await _http.GetStringAsync($"http://127.0.0.1:{_monitorPort}/accstatz"));
doc.RootElement.GetProperty("total_connections").GetInt32().ShouldBeGreaterThanOrEqualTo(1);
}
/// <summary>
/// Go: TestMonitorAccountStatz (line 4330).
/// Verifies /accstatz total_subscriptions updates after a client subscribes.
/// </summary>
[Fact]
public async Task Accstatz_total_subscriptions_updates_after_subscribe()
{
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _natsPort));
var buf = new byte[4096];
_ = await sock.ReceiveAsync(buf, SocketFlags.None);
await sock.SendAsync("CONNECT {}\r\nSUB test 1\r\n"u8.ToArray(), SocketFlags.None);
await Task.Delay(200);
var doc = JsonDocument.Parse(await _http.GetStringAsync($"http://127.0.0.1:{_monitorPort}/accstatz"));
doc.RootElement.GetProperty("total_subscriptions").GetInt32().ShouldBeGreaterThanOrEqualTo(1);
}
/// <summary>
/// Go: TestMonitorAccountz (line 4300).
/// Verifies /accountz includes per-account fields: name, connections, subscriptions.
/// </summary>
[Fact]
public async Task Accountz_includes_per_account_fields()
{
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _natsPort));
var buf = new byte[4096];
_ = await sock.ReceiveAsync(buf, SocketFlags.None);
await sock.SendAsync("CONNECT {}\r\nSUB test 1\r\n"u8.ToArray(), SocketFlags.None);
await Task.Delay(200);
var body = await _http.GetStringAsync($"http://127.0.0.1:{_monitorPort}/accountz");
body.ShouldContain("\"name\"");
body.ShouldContain("\"connections\"");
body.ShouldContain("\"subscriptions\"");
}
/// <summary>
/// Go: TestMonitorGateway (line 2880).
/// Verifies /gatewayz includes num_gateways field.
/// </summary>
[Fact]
public async Task Gatewayz_includes_num_gateways()
{
var body = await _http.GetStringAsync($"http://127.0.0.1:{_monitorPort}/gatewayz");
body.ShouldContain("gateways");
}
/// <summary>
/// Go: TestMonitorLeafNode (line 3112).
/// Verifies /leafz includes num_leafs field.
/// </summary>
[Fact]
public async Task Leafz_includes_num_leafs()
{
var body = await _http.GetStringAsync($"http://127.0.0.1:{_monitorPort}/leafz");
body.ShouldContain("leafs");
}
}

View File

@@ -0,0 +1,354 @@
// Go: TestSubsz server/monitor_test.go:1538
// Go: TestMonitorSubszDetails server/monitor_test.go:1609
// Go: TestMonitorSubszWithOffsetAndLimit server/monitor_test.go:1642
// Go: TestMonitorSubszTestPubSubject server/monitor_test.go:1675
// Go: TestMonitorSubszMultiAccount server/monitor_test.go:1709
// Go: TestMonitorSubszMultiAccountWithOffsetAndLimit server/monitor_test.go:1777
using System.Net;
using System.Net.Http.Json;
using System.Net.Sockets;
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server.Monitoring;
using NATS.Server.TestUtilities;
namespace NATS.Server.Monitoring.Tests.Monitoring;
/// <summary>
/// Tests covering /subz (subscriptionsz) endpoint behavior,
/// ported from the Go server's monitor_test.go.
/// </summary>
public class MonitorSubszTests : IAsyncLifetime
{
private readonly NatsServer _server;
private readonly int _natsPort;
private readonly int _monitorPort;
private readonly CancellationTokenSource _cts = new();
private readonly HttpClient _http = new();
public MonitorSubszTests()
{
_natsPort = TestPortAllocator.GetFreePort();
_monitorPort = TestPortAllocator.GetFreePort();
_server = new NatsServer(
new NatsOptions { Port = _natsPort, MonitorPort = _monitorPort },
NullLoggerFactory.Instance);
}
public async Task InitializeAsync()
{
_ = _server.StartAsync(_cts.Token);
await _server.WaitForReadyAsync();
for (var i = 0; i < 50; i++)
{
try
{
var probe = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/healthz");
if (probe.IsSuccessStatusCode) break;
}
catch (HttpRequestException) { }
await Task.Delay(50);
}
}
public async Task DisposeAsync()
{
_http.Dispose();
await _cts.CancelAsync();
_server.Dispose();
}
/// <summary>
/// Go: TestSubsz (line 1538).
/// Verifies /subz returns valid JSON with server_id, num_subscriptions fields.
/// </summary>
[Fact]
public async Task Subz_returns_valid_json_with_server_id()
{
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/subz");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var subsz = await response.Content.ReadFromJsonAsync<Subsz>();
subsz.ShouldNotBeNull();
subsz.Id.ShouldNotBeNullOrEmpty();
}
/// <summary>
/// Go: TestSubsz (line 1538).
/// Verifies /subz reports num_subscriptions after clients subscribe.
/// </summary>
[Fact]
public async Task Subz_reports_subscription_count()
{
using var sock = await ConnectClientAsync("SUB foo 1\r\n");
await Task.Delay(200);
var subsz = await _http.GetFromJsonAsync<Subsz>($"http://127.0.0.1:{_monitorPort}/subz");
subsz.ShouldNotBeNull();
subsz.NumSubs.ShouldBeGreaterThanOrEqualTo(1u);
}
/// <summary>
/// Go: TestMonitorSubszDetails (line 1609).
/// Verifies /subz?subs=1 returns subscription details with subject info.
/// </summary>
[Fact]
public async Task Subz_with_subs_returns_subscription_details()
{
using var sock = await ConnectClientAsync("SUB foo.* 1\r\nSUB foo.bar 2\r\nSUB foo.foo 3\r\n");
await Task.Delay(200);
var subsz = await _http.GetFromJsonAsync<Subsz>($"http://127.0.0.1:{_monitorPort}/subz?subs=1");
subsz.ShouldNotBeNull();
// Go: sl.NumSubs != 3, sl.Total != 3, len(sl.Subs) != 3
subsz.NumSubs.ShouldBeGreaterThanOrEqualTo(3u);
subsz.Total.ShouldBeGreaterThanOrEqualTo(3);
subsz.Subs.Length.ShouldBeGreaterThanOrEqualTo(3);
}
/// <summary>
/// Go: TestMonitorSubszDetails (line 1609).
/// Verifies subscription detail entries contain the correct subject names.
/// </summary>
[Fact]
public async Task Subz_detail_entries_contain_subject_names()
{
using var sock = await ConnectClientAsync("SUB foo.bar 1\r\nSUB foo.baz 2\r\n");
await Task.Delay(200);
var subsz = await _http.GetFromJsonAsync<Subsz>($"http://127.0.0.1:{_monitorPort}/subz?subs=1");
subsz.ShouldNotBeNull();
subsz.Subs.ShouldContain(s => s.Subject == "foo.bar");
subsz.Subs.ShouldContain(s => s.Subject == "foo.baz");
}
/// <summary>
/// Go: TestMonitorSubszWithOffsetAndLimit (line 1642).
/// Verifies /subz pagination with offset and limit parameters.
/// </summary>
[Fact]
public async Task Subz_pagination_with_offset_and_limit()
{
// Create many subscriptions
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _natsPort));
var buf = new byte[4096];
_ = await sock.ReceiveAsync(buf, SocketFlags.None);
await sock.SendAsync("CONNECT {}\r\n"u8.ToArray(), SocketFlags.None);
for (var i = 0; i < 200; i++)
await sock.SendAsync(System.Text.Encoding.ASCII.GetBytes($"SUB foo.{i} {i + 1}\r\n"), SocketFlags.None);
await Task.Delay(300);
var subsz = await _http.GetFromJsonAsync<Subsz>($"http://127.0.0.1:{_monitorPort}/subz?subs=1&offset=10&limit=100");
subsz.ShouldNotBeNull();
// Go: sl.NumSubs != 200, sl.Total != 200, sl.Offset != 10, sl.Limit != 100, len(sl.Subs) != 100
subsz.NumSubs.ShouldBeGreaterThanOrEqualTo(200u);
subsz.Total.ShouldBeGreaterThanOrEqualTo(200);
subsz.Offset.ShouldBe(10);
subsz.Limit.ShouldBe(100);
subsz.Subs.Length.ShouldBe(100);
}
/// <summary>
/// Go: TestMonitorSubszTestPubSubject (line 1675).
/// Verifies /subz?test=foo.foo filters subscriptions matching a concrete subject.
/// </summary>
[Fact]
public async Task Subz_test_subject_filters_matching_subscriptions()
{
using var sock = await ConnectClientAsync("SUB foo.* 1\r\nSUB foo.bar 2\r\nSUB foo.foo 3\r\n");
await Task.Delay(200);
// foo.foo matches "foo.*" and "foo.foo" but not "foo.bar"
var subsz = await _http.GetFromJsonAsync<Subsz>($"http://127.0.0.1:{_monitorPort}/subz?subs=1&test=foo.foo");
subsz.ShouldNotBeNull();
// Go: sl.Total != 2, len(sl.Subs) != 2
subsz.Total.ShouldBe(2);
subsz.Subs.Length.ShouldBe(2);
}
/// <summary>
/// Go: TestMonitorSubszTestPubSubject (line 1675).
/// Verifies /subz?test=foo returns no matches when no subscription matches exactly.
/// </summary>
[Fact]
public async Task Subz_test_subject_no_match_returns_empty()
{
using var sock = await ConnectClientAsync("SUB foo.* 1\r\nSUB foo.bar 2\r\n");
await Task.Delay(200);
// "foo" alone does not match "foo.*" or "foo.bar"
var subsz = await _http.GetFromJsonAsync<Subsz>($"http://127.0.0.1:{_monitorPort}/subz?subs=1&test=foo");
subsz.ShouldNotBeNull();
subsz.Subs.Length.ShouldBe(0);
}
/// <summary>
/// Go: TestSubsz (line 1538).
/// Verifies /subz default has no subscription details (subs not requested).
/// </summary>
[Fact]
public async Task Subz_default_does_not_include_details()
{
using var sock = await ConnectClientAsync("SUB foo 1\r\n");
await Task.Delay(200);
var subsz = await _http.GetFromJsonAsync<Subsz>($"http://127.0.0.1:{_monitorPort}/subz");
subsz.ShouldNotBeNull();
subsz.Subs.Length.ShouldBe(0);
}
/// <summary>
/// Go: TestSubsz (line 1538).
/// Verifies /subscriptionsz works as an alias for /subz.
/// </summary>
[Fact]
public async Task Subscriptionsz_is_alias_for_subz()
{
using var sock = await ConnectClientAsync("SUB foo 1\r\n");
await Task.Delay(200);
var subsz = await _http.GetFromJsonAsync<Subsz>($"http://127.0.0.1:{_monitorPort}/subscriptionsz");
subsz.ShouldNotBeNull();
subsz.Id.ShouldNotBeNullOrEmpty();
subsz.NumSubs.ShouldBeGreaterThanOrEqualTo(1u);
}
/// <summary>
/// Go: TestSubsz (line 1538).
/// Verifies /subz JSON uses correct Go-compatible field names.
/// </summary>
[Fact]
public async Task Subz_json_uses_go_field_names()
{
var body = await _http.GetStringAsync($"http://127.0.0.1:{_monitorPort}/subz");
body.ShouldContain("\"server_id\"");
body.ShouldContain("\"num_subscriptions\"");
}
/// <summary>
/// Go: TestMonitorSubszDetails (line 1609).
/// Verifies subscription details include sid and cid fields.
/// </summary>
[Fact]
public async Task Subz_details_include_sid_and_cid()
{
using var sock = await ConnectClientAsync("SUB foo 99\r\n");
await Task.Delay(200);
var subsz = await _http.GetFromJsonAsync<Subsz>($"http://127.0.0.1:{_monitorPort}/subz?subs=1");
subsz.ShouldNotBeNull();
subsz.Subs.Length.ShouldBeGreaterThanOrEqualTo(1);
var sub = subsz.Subs.First(s => s.Subject == "foo");
sub.Sid.ShouldBe("99");
sub.Cid.ShouldBeGreaterThan(0UL);
}
/// <summary>
/// Go: TestSubsz (line 1538).
/// Verifies /subz returns HTTP 200 OK.
/// </summary>
[Fact]
public async Task Subz_returns_http_200()
{
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/subz");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
}
/// <summary>
/// Go: TestSubsz (line 1538).
/// Verifies /subz num_cache reflects the cache state of the subscription trie.
/// </summary>
[Fact]
public async Task Subz_includes_num_cache()
{
var subsz = await _http.GetFromJsonAsync<Subsz>($"http://127.0.0.1:{_monitorPort}/subz");
subsz.ShouldNotBeNull();
// num_cache should be >= 0
subsz.NumCache.ShouldBeGreaterThanOrEqualTo(0);
}
/// <summary>
/// Go: TestMonitorSubszWithOffsetAndLimit (line 1642).
/// Verifies /subz with offset=0 and limit=0 uses defaults.
/// </summary>
[Fact]
public async Task Subz_offset_zero_uses_default_limit()
{
var subsz = await _http.GetFromJsonAsync<Subsz>($"http://127.0.0.1:{_monitorPort}/subz?offset=0");
subsz.ShouldNotBeNull();
subsz.Offset.ShouldBe(0);
subsz.Limit.ShouldBe(1024); // default limit
}
/// <summary>
/// Go: TestMonitorConcurrentMonitoring (line 2148).
/// Verifies concurrent /subz requests do not cause errors.
/// </summary>
[Fact]
public async Task Subz_handles_concurrent_requests()
{
using var sock = await ConnectClientAsync("SUB foo 1\r\n");
await Task.Delay(200);
var tasks = Enumerable.Range(0, 10).Select(async _ =>
{
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/subz");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
});
await Task.WhenAll(tasks);
}
/// <summary>
/// Go: TestMonitorSubszTestPubSubject (line 1675).
/// Verifies /subz?test with wildcard subject foo.* matches foo.bar and foo.baz.
/// </summary>
[Fact]
public async Task Subz_test_wildcard_match()
{
using var sock = await ConnectClientAsync("SUB foo.bar 1\r\nSUB foo.baz 2\r\nSUB bar.x 3\r\n");
await Task.Delay(200);
// test=foo.bar should match foo.bar literal
var subsz = await _http.GetFromJsonAsync<Subsz>($"http://127.0.0.1:{_monitorPort}/subz?subs=1&test=foo.bar");
subsz.ShouldNotBeNull();
subsz.Total.ShouldBe(1);
subsz.Subs.Length.ShouldBe(1);
subsz.Subs[0].Subject.ShouldBe("foo.bar");
}
/// <summary>
/// Go: TestMonitorSubszMultiAccount (line 1709).
/// Verifies /subz now timestamp is plausible.
/// </summary>
[Fact]
public async Task Subz_now_is_plausible_timestamp()
{
var before = DateTime.UtcNow;
var subsz = await _http.GetFromJsonAsync<Subsz>($"http://127.0.0.1:{_monitorPort}/subz");
var after = DateTime.UtcNow;
subsz.ShouldNotBeNull();
subsz.Now.ShouldBeGreaterThanOrEqualTo(before.AddSeconds(-1));
subsz.Now.ShouldBeLessThanOrEqualTo(after.AddSeconds(1));
}
private async Task<Socket> ConnectClientAsync(string extraCommands)
{
var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _natsPort));
var buf = new byte[4096];
_ = await sock.ReceiveAsync(buf, SocketFlags.None);
await sock.SendAsync(System.Text.Encoding.ASCII.GetBytes($"CONNECT {{}}\r\n{extraCommands}"), SocketFlags.None);
return sock;
}
}

View File

@@ -0,0 +1,521 @@
// Go: TestMonitorHandleVarz server/monitor_test.go:275
// Go: TestMyUptime server/monitor_test.go:135
// Go: TestMonitorVarzSubscriptionsResetProperly server/monitor_test.go:257
// Go: TestMonitorNoPort server/monitor_test.go:168
// Go: TestMonitorHTTPBasePath server/monitor_test.go:220
// Go: TestMonitorHandleRoot server/monitor_test.go:1819
// Go: TestMonitorServerIDs server/monitor_test.go:2410
// Go: TestMonitorHttpStatsNoUpdatedWhenUsingServerFuncs server/monitor_test.go:2435
// Go: TestMonitorVarzRaces server/monitor_test.go:2641
using System.Net;
using System.Net.Http.Json;
using System.Net.Sockets;
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server.Monitoring;
using NATS.Server.TestUtilities;
namespace NATS.Server.Monitoring.Tests.Monitoring;
/// <summary>
/// Tests covering /varz endpoint behavior, ported from the Go server's monitor_test.go.
/// </summary>
public class MonitorVarzTests : IAsyncLifetime
{
private readonly NatsServer _server;
private readonly int _natsPort;
private readonly int _monitorPort;
private readonly CancellationTokenSource _cts = new();
private readonly HttpClient _http = new();
public MonitorVarzTests()
{
_natsPort = TestPortAllocator.GetFreePort();
_monitorPort = TestPortAllocator.GetFreePort();
_server = new NatsServer(
new NatsOptions { Port = _natsPort, MonitorPort = _monitorPort },
NullLoggerFactory.Instance);
}
public async Task InitializeAsync()
{
_ = _server.StartAsync(_cts.Token);
await _server.WaitForReadyAsync();
for (var i = 0; i < 50; i++)
{
try
{
var probe = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/healthz");
if (probe.IsSuccessStatusCode) break;
}
catch (HttpRequestException) { }
await Task.Delay(50);
}
}
public async Task DisposeAsync()
{
_http.Dispose();
await _cts.CancelAsync();
_server.Dispose();
}
/// <summary>
/// Go: TestMonitorHandleVarz (line 275), mode=0.
/// Verifies /varz returns valid JSON with server identity fields including
/// server_id, version, start time within 10s, host, port, max_payload.
/// </summary>
[Fact]
public async Task Varz_returns_server_identity_and_start_within_10_seconds()
{
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/varz");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var varz = await response.Content.ReadFromJsonAsync<Varz>();
varz.ShouldNotBeNull();
varz.Id.ShouldNotBeNullOrEmpty();
varz.Version.ShouldNotBeNullOrEmpty();
// Go: if time.Since(v.Start) > 10*time.Second { t.Fatal(...) }
(DateTime.UtcNow - varz.Start).ShouldBeLessThan(TimeSpan.FromSeconds(10));
}
/// <summary>
/// Go: TestMonitorHandleVarz (line 275), after connecting client.
/// Verifies /varz tracks connections, in_msgs, out_msgs, in_bytes, out_bytes
/// after a client connects, subscribes, and publishes.
/// </summary>
[Fact]
public async Task Varz_tracks_connection_stats_after_client_pubsub()
{
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _natsPort));
var buf = new byte[4096];
_ = await sock.ReceiveAsync(buf, SocketFlags.None);
// Subscribe, publish 5-byte payload "hello", then flush
await sock.SendAsync("CONNECT {}\r\nSUB foo 1\r\nPUB foo 5\r\nhello\r\n"u8.ToArray(), SocketFlags.None);
await Task.Delay(200);
var varz = await _http.GetFromJsonAsync<Varz>($"http://127.0.0.1:{_monitorPort}/varz");
varz.ShouldNotBeNull();
// Go: v.Connections != 1
varz.Connections.ShouldBeGreaterThanOrEqualTo(1);
// Go: v.TotalConnections < 1
varz.TotalConnections.ShouldBeGreaterThanOrEqualTo(1UL);
// Go: v.InMsgs != 1
varz.InMsgs.ShouldBeGreaterThanOrEqualTo(1L);
// Go: v.InBytes != 5
varz.InBytes.ShouldBeGreaterThanOrEqualTo(5L);
}
/// <summary>
/// Go: TestMonitorHandleVarz (line 275).
/// Verifies that /varz reports subscriptions count after a client subscribes.
/// </summary>
[Fact]
public async Task Varz_reports_subscription_count()
{
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _natsPort));
var buf = new byte[4096];
_ = await sock.ReceiveAsync(buf, SocketFlags.None);
await sock.SendAsync("CONNECT {}\r\nSUB test 1\r\nSUB test2 2\r\n"u8.ToArray(), SocketFlags.None);
await Task.Delay(200);
var varz = await _http.GetFromJsonAsync<Varz>($"http://127.0.0.1:{_monitorPort}/varz");
varz.ShouldNotBeNull();
varz.Subscriptions.ShouldBeGreaterThanOrEqualTo(2u);
}
/// <summary>
/// Go: TestMonitorVarzSubscriptionsResetProperly (line 257).
/// Verifies /varz subscriptions count remains stable across multiple calls,
/// and does not double on each request.
/// </summary>
[Fact]
public async Task Varz_subscriptions_do_not_double_across_repeated_calls()
{
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _natsPort));
var buf = new byte[4096];
_ = await sock.ReceiveAsync(buf, SocketFlags.None);
await sock.SendAsync("CONNECT {}\r\nSUB test 1\r\n"u8.ToArray(), SocketFlags.None);
await Task.Delay(200);
var varz1 = await _http.GetFromJsonAsync<Varz>($"http://127.0.0.1:{_monitorPort}/varz");
var subs1 = varz1!.Subscriptions;
var varz2 = await _http.GetFromJsonAsync<Varz>($"http://127.0.0.1:{_monitorPort}/varz");
var subs2 = varz2!.Subscriptions;
// Go: check that we get same number back (not doubled)
subs2.ShouldBe(subs1);
}
/// <summary>
/// Go: TestMonitorHandleVarz (line 275).
/// Verifies /varz exposes JetStream config and stats sections.
/// </summary>
[Fact]
public async Task Varz_includes_jetstream_section()
{
var varz = await _http.GetFromJsonAsync<Varz>($"http://127.0.0.1:{_monitorPort}/varz");
varz.ShouldNotBeNull();
varz.JetStream.ShouldNotBeNull();
varz.JetStream.Config.ShouldNotBeNull();
varz.JetStream.Stats.ShouldNotBeNull();
}
/// <summary>
/// Go: TestMonitorHandleVarz (line 275).
/// Verifies /varz includes runtime metrics: mem > 0, cores > 0.
/// </summary>
[Fact]
public async Task Varz_includes_runtime_metrics()
{
var varz = await _http.GetFromJsonAsync<Varz>($"http://127.0.0.1:{_monitorPort}/varz");
varz.ShouldNotBeNull();
varz.Mem.ShouldBeGreaterThan(0L);
varz.Cores.ShouldBeGreaterThan(0);
}
/// <summary>
/// Go: TestMonitorHandleVarz (line 275).
/// Verifies /varz uptime string is non-empty and matches expected format (e.g. "0s", "1m2s").
/// </summary>
[Fact]
public async Task Varz_uptime_is_formatted_string()
{
var varz = await _http.GetFromJsonAsync<Varz>($"http://127.0.0.1:{_monitorPort}/varz");
varz.ShouldNotBeNull();
varz.Uptime.ShouldNotBeNullOrEmpty();
// Uptime should end with 's' (seconds), matching Go format like "0s", "1m0s"
varz.Uptime.ShouldEndWith("s");
}
/// <summary>
/// Go: TestMyUptime (line 135).
/// Verifies the uptime formatting logic produces correct duration strings.
/// Tests: 22s, 4m22s, 4h4m22s, 32d4h4m22s.
/// </summary>
[Theory]
[InlineData(22, "22s")]
[InlineData(22 + 4 * 60, "4m22s")]
[InlineData(22 + 4 * 60 + 4 * 3600, "4h4m22s")]
[InlineData(22 + 4 * 60 + 4 * 3600 + 32 * 86400, "32d4h4m22s")]
public void Uptime_format_matches_go_myUptime(int totalSeconds, string expected)
{
var ts = TimeSpan.FromSeconds(totalSeconds);
var result = FormatUptime(ts);
result.ShouldBe(expected);
}
/// <summary>
/// Go: TestMonitorHandleVarz (line 275).
/// Verifies /varz serializes with correct Go JSON field names.
/// </summary>
[Fact]
public async Task Varz_json_uses_go_field_names()
{
var response = await _http.GetStringAsync($"http://127.0.0.1:{_monitorPort}/varz");
response.ShouldContain("\"server_id\"");
response.ShouldContain("\"server_name\"");
response.ShouldContain("\"in_msgs\"");
response.ShouldContain("\"out_msgs\"");
response.ShouldContain("\"in_bytes\"");
response.ShouldContain("\"out_bytes\"");
response.ShouldContain("\"max_payload\"");
response.ShouldContain("\"total_connections\"");
response.ShouldContain("\"slow_consumers\"");
}
/// <summary>
/// Go: TestMonitorHandleVarz (line 275).
/// Verifies /varz includes nested configuration sections for cluster, gateway, leaf.
/// </summary>
[Fact]
public async Task Varz_includes_cluster_gateway_leaf_sections()
{
var varz = await _http.GetFromJsonAsync<Varz>($"http://127.0.0.1:{_monitorPort}/varz");
varz.ShouldNotBeNull();
varz.Cluster.ShouldNotBeNull();
varz.Gateway.ShouldNotBeNull();
varz.Leaf.ShouldNotBeNull();
}
/// <summary>
/// Go: TestMonitorHandleVarz (line 275).
/// Verifies /varz max_payload defaults to 1MB.
/// </summary>
[Fact]
public async Task Varz_max_payload_defaults_to_1MB()
{
var varz = await _http.GetFromJsonAsync<Varz>($"http://127.0.0.1:{_monitorPort}/varz");
varz.ShouldNotBeNull();
varz.MaxPayload.ShouldBe(1024 * 1024);
}
/// <summary>
/// Go: TestMonitorHandleVarz (line 275).
/// Verifies /varz host and port match the configured values.
/// </summary>
[Fact]
public async Task Varz_host_and_port_match_configuration()
{
var varz = await _http.GetFromJsonAsync<Varz>($"http://127.0.0.1:{_monitorPort}/varz");
varz.ShouldNotBeNull();
varz.Port.ShouldBe(_natsPort);
varz.Host.ShouldNotBeNullOrEmpty();
}
/// <summary>
/// Go: TestMonitorServerIDs (line 2410).
/// Verifies /varz and /connz both expose the same server_id.
/// </summary>
[Fact]
public async Task Varz_and_connz_report_matching_server_id()
{
var varz = await _http.GetFromJsonAsync<Varz>($"http://127.0.0.1:{_monitorPort}/varz");
var connz = await _http.GetFromJsonAsync<Connz>($"http://127.0.0.1:{_monitorPort}/connz");
varz.ShouldNotBeNull();
connz.ShouldNotBeNull();
varz.Id.ShouldNotBeNullOrEmpty();
connz.Id.ShouldBe(varz.Id);
}
/// <summary>
/// Go: TestMonitorHttpStatsNoUpdatedWhenUsingServerFuncs (line 2435).
/// Verifies /varz http_req_stats tracks endpoint hit counts and increments on each call.
/// </summary>
[Fact]
public async Task Varz_http_req_stats_increment_on_each_request()
{
// First request establishes baseline
await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/varz");
var varz = await _http.GetFromJsonAsync<Varz>($"http://127.0.0.1:{_monitorPort}/varz");
varz.ShouldNotBeNull();
varz.HttpReqStats.ShouldContainKey("/varz");
var count = varz.HttpReqStats["/varz"];
count.ShouldBeGreaterThanOrEqualTo(2UL);
}
/// <summary>
/// Go: TestMonitorHandleVarz (line 275).
/// Verifies /varz includes slow_consumer_stats section with breakdown fields.
/// </summary>
[Fact]
public async Task Varz_includes_slow_consumer_stats_breakdown()
{
var varz = await _http.GetFromJsonAsync<Varz>($"http://127.0.0.1:{_monitorPort}/varz");
varz.ShouldNotBeNull();
varz.SlowConsumerStats.ShouldNotBeNull();
varz.SlowConsumerStats.Clients.ShouldBeGreaterThanOrEqualTo(0UL);
varz.SlowConsumerStats.Routes.ShouldBeGreaterThanOrEqualTo(0UL);
varz.SlowConsumerStats.Gateways.ShouldBeGreaterThanOrEqualTo(0UL);
varz.SlowConsumerStats.Leafs.ShouldBeGreaterThanOrEqualTo(0UL);
}
/// <summary>
/// Go: TestMonitorHandleVarz (line 275).
/// Verifies /varz includes proto version field.
/// </summary>
[Fact]
public async Task Varz_includes_proto_version()
{
var varz = await _http.GetFromJsonAsync<Varz>($"http://127.0.0.1:{_monitorPort}/varz");
varz.ShouldNotBeNull();
varz.Proto.ShouldBeGreaterThanOrEqualTo(0);
}
/// <summary>
/// Go: TestMonitorHandleVarz (line 275).
/// Verifies /varz config_load_time is set.
/// </summary>
[Fact]
public async Task Varz_config_load_time_is_set()
{
var varz = await _http.GetFromJsonAsync<Varz>($"http://127.0.0.1:{_monitorPort}/varz");
varz.ShouldNotBeNull();
varz.ConfigLoadTime.ShouldBeGreaterThan(DateTime.MinValue);
}
/// <summary>
/// Go: TestMonitorVarzRaces (line 2641).
/// Verifies concurrent /varz requests do not cause errors or data corruption.
/// </summary>
[Fact]
public async Task Varz_handles_concurrent_requests_without_errors()
{
var tasks = Enumerable.Range(0, 10).Select(async _ =>
{
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/varz");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var v = await response.Content.ReadFromJsonAsync<Varz>();
v.ShouldNotBeNull();
v.Id.ShouldNotBeNullOrEmpty();
});
await Task.WhenAll(tasks);
}
/// <summary>
/// Go: TestMonitorHandleVarz (line 275).
/// Verifies /varz out_msgs increments when messages are delivered to subscribers.
/// </summary>
[Fact]
public async Task Varz_out_msgs_increments_on_delivery()
{
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _natsPort));
var buf = new byte[4096];
_ = await sock.ReceiveAsync(buf, SocketFlags.None);
// Subscribe then publish to matched subject
await sock.SendAsync("CONNECT {}\r\nSUB foo 1\r\nPUB foo 5\r\nhello\r\n"u8.ToArray(), SocketFlags.None);
await Task.Delay(200);
var varz = await _http.GetFromJsonAsync<Varz>($"http://127.0.0.1:{_monitorPort}/varz");
varz.ShouldNotBeNull();
// Message was published and delivered to the subscriber, so out_msgs >= 1
varz.OutMsgs.ShouldBeGreaterThanOrEqualTo(1L);
varz.OutBytes.ShouldBeGreaterThanOrEqualTo(5L);
}
/// <summary>
/// Go: TestMonitorHandleVarz (line 275).
/// Verifies /varz includes MQTT section in response.
/// </summary>
[Fact]
public async Task Varz_includes_mqtt_section()
{
var varz = await _http.GetFromJsonAsync<Varz>($"http://127.0.0.1:{_monitorPort}/varz");
varz.ShouldNotBeNull();
varz.Mqtt.ShouldNotBeNull();
}
/// <summary>
/// Go: TestMonitorHandleVarz (line 275).
/// Verifies /varz includes websocket section.
/// </summary>
[Fact]
public async Task Varz_includes_websocket_section()
{
var varz = await _http.GetFromJsonAsync<Varz>($"http://127.0.0.1:{_monitorPort}/varz");
varz.ShouldNotBeNull();
varz.Websocket.ShouldNotBeNull();
}
/// <summary>
/// Go: TestMonitorHandleRoot (line 1819).
/// Verifies GET / returns a listing of available monitoring endpoints.
/// </summary>
[Fact]
public async Task Root_endpoint_returns_endpoint_listing()
{
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var body = await response.Content.ReadAsStringAsync();
body.ShouldContain("varz");
body.ShouldContain("connz");
body.ShouldContain("healthz");
}
/// <summary>
/// Go: TestMonitorHandleVarz (line 275).
/// Verifies /varz total_connections tracks cumulative connections, not just active.
/// </summary>
[Fact]
public async Task Varz_total_connections_tracks_cumulative_count()
{
// Connect and disconnect a client
var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _natsPort));
var buf = new byte[4096];
_ = await sock.ReceiveAsync(buf, SocketFlags.None);
await sock.SendAsync("CONNECT {}\r\n"u8.ToArray(), SocketFlags.None);
await Task.Delay(100);
sock.Shutdown(SocketShutdown.Both);
sock.Dispose();
await Task.Delay(300);
// Connect a second client (still active)
using var sock2 = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await sock2.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _natsPort));
buf = new byte[4096];
_ = await sock2.ReceiveAsync(buf, SocketFlags.None);
await sock2.SendAsync("CONNECT {}\r\n"u8.ToArray(), SocketFlags.None);
await Task.Delay(200);
var varz = await _http.GetFromJsonAsync<Varz>($"http://127.0.0.1:{_monitorPort}/varz");
varz.ShouldNotBeNull();
// Total should be >= 2 (both connections counted), active should be 1
varz.TotalConnections.ShouldBeGreaterThanOrEqualTo(2UL);
varz.Connections.ShouldBeGreaterThanOrEqualTo(1);
}
/// <summary>
/// Go: TestMonitorNoPort (line 168).
/// Verifies that when no monitor port is configured, monitoring endpoints are not accessible.
/// This is a standalone test since it uses a different server configuration.
/// </summary>
[Fact]
public async Task Monitor_not_accessible_when_port_not_configured()
{
var natsPort = TestPortAllocator.GetFreePort();
var server = new NatsServer(
new NatsOptions { Port = natsPort, MonitorPort = 0 },
NullLoggerFactory.Instance);
var cts = new CancellationTokenSource();
_ = server.StartAsync(cts.Token);
await server.WaitForReadyAsync();
try
{
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(2) };
// Try a random port where no monitor should be running
var act = async () => await http.GetAsync("http://127.0.0.1:11245/varz");
await act.ShouldThrowAsync<Exception>();
}
finally
{
await cts.CancelAsync();
server.Dispose();
}
}
/// <summary>
/// Go: TestMonitorHandleVarz (line 275).
/// Verifies /varz now field returns a plausible UTC timestamp.
/// </summary>
[Fact]
public async Task Varz_now_is_plausible_utc_timestamp()
{
var before = DateTime.UtcNow;
var varz = await _http.GetFromJsonAsync<Varz>($"http://127.0.0.1:{_monitorPort}/varz");
var after = DateTime.UtcNow;
varz.ShouldNotBeNull();
varz.Now.ShouldBeGreaterThanOrEqualTo(before.AddSeconds(-1));
varz.Now.ShouldBeLessThanOrEqualTo(after.AddSeconds(1));
}
// Helper: matches Go server myUptime() format
private static string FormatUptime(TimeSpan ts)
{
if (ts.TotalDays >= 1)
return $"{(int)ts.TotalDays}d{ts.Hours}h{ts.Minutes}m{ts.Seconds}s";
if (ts.TotalHours >= 1)
return $"{(int)ts.TotalHours}h{ts.Minutes}m{ts.Seconds}s";
if (ts.TotalMinutes >= 1)
return $"{(int)ts.TotalMinutes}m{ts.Seconds}s";
return $"{(int)ts.TotalSeconds}s";
}
}

View File

@@ -0,0 +1,39 @@
using System.Text.Json;
using NATS.Server.Monitoring;
namespace NATS.Server.Monitoring.Tests.Monitoring;
public class MonitoringHealthAndSortParityBatch1Tests
{
[Fact]
public void SortOpt_IsValid_matches_defined_values()
{
foreach (var value in Enum.GetValues<SortOpt>())
value.IsValid().ShouldBeTrue();
((SortOpt)999).IsValid().ShouldBeFalse();
}
[Fact]
public void HealthStatus_ok_serializes_with_go_shape_fields()
{
var json = JsonSerializer.Serialize(HealthStatus.Ok());
json.ShouldContain("\"status\":\"ok\"");
json.ShouldContain("\"status_code\":200");
json.ShouldContain("\"errors\":[]");
}
[Fact]
public void HealthzError_serializes_enum_as_string()
{
var json = JsonSerializer.Serialize(new HealthzError
{
Type = HealthzErrorType.JetStream,
Error = "jetstream unavailable",
});
json.ShouldContain("\"type\":\"JetStream\"");
json.ShouldContain("\"error\":\"jetstream unavailable\"");
}
}

View File

@@ -0,0 +1,87 @@
using System.Net;
using System.Net.Sockets;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server.TestUtilities;
namespace NATS.Server.Monitoring.Tests;
public class PprofEndpointTests
{
[Fact]
public async Task Debug_pprof_endpoint_returns_profile_index_when_profport_enabled()
{
await using var fx = await PprofMonitorFixture.StartWithProfilingAsync();
var body = await fx.GetStringAsync("/debug/pprof");
body.ShouldContain("profiles");
}
}
internal sealed class PprofMonitorFixture : IAsyncDisposable
{
private readonly NatsServer _server;
private readonly CancellationTokenSource _cts;
private readonly HttpClient _http;
private readonly int _monitorPort;
private PprofMonitorFixture(NatsServer server, CancellationTokenSource cts, HttpClient http, int monitorPort)
{
_server = server;
_cts = cts;
_http = http;
_monitorPort = monitorPort;
}
public static async Task<PprofMonitorFixture> StartWithProfilingAsync()
{
var monitorPort = TestPortAllocator.GetFreePort();
var options = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
MonitorPort = monitorPort,
ProfPort = monitorPort,
};
var server = new NatsServer(options, NullLoggerFactory.Instance);
var cts = new CancellationTokenSource();
_ = server.StartAsync(cts.Token);
await server.WaitForReadyAsync();
var http = new HttpClient();
for (var i = 0; i < 50; i++)
{
try
{
var response = await http.GetAsync($"http://127.0.0.1:{monitorPort}/healthz");
if (response.IsSuccessStatusCode)
break;
}
catch
{
}
await Task.Delay(50);
}
return new PprofMonitorFixture(server, cts, http, monitorPort);
}
public Task<string> GetStringAsync(string path)
{
return _http.GetStringAsync($"http://127.0.0.1:{_monitorPort}{path}");
}
public Task<byte[]> GetBytesAsync(string path)
{
return _http.GetByteArrayAsync($"http://127.0.0.1:{_monitorPort}{path}");
}
public async ValueTask DisposeAsync()
{
_http.Dispose();
await _cts.CancelAsync();
_server.Dispose();
_cts.Dispose();
}
}

View File

@@ -0,0 +1,18 @@
using System.Text.Json;
namespace NATS.Server.Monitoring.Tests.Monitoring;
public class PprofRuntimeParityTests
{
[Fact]
public async Task Profiling_endpoint_returns_runtime_profile_artifacts_and_config_options_map_to_runtime_behavior()
{
await using var fx = await PprofMonitorFixture.StartWithProfilingAsync();
var payload = await fx.GetBytesAsync("/debug/pprof/profile?seconds=2");
var doc = JsonDocument.Parse(payload);
doc.RootElement.GetProperty("profile").GetString().ShouldBe("cpu");
doc.RootElement.GetProperty("seconds").GetInt32().ShouldBe(2);
doc.RootElement.GetProperty("thread_count").GetInt32().ShouldBeGreaterThan(0);
}
}

View File

@@ -0,0 +1,65 @@
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text.Json;
using NATS.Server.Monitoring;
namespace NATS.Server.Monitoring.Tests.Monitoring;
public class TlsPeerCertParityTests
{
[Fact]
public void TLSPeerCert_serializes_go_shape_fields()
{
var cert = new TLSPeerCert
{
Subject = "CN=peer",
SubjectPKISha256 = new string('a', 64),
CertSha256 = new string('b', 64),
};
var json = JsonSerializer.Serialize(cert);
json.ShouldContain("\"subject\":\"CN=peer\"");
json.ShouldContain("\"subject_pk_sha256\":");
json.ShouldContain("\"cert_sha256\":");
}
[Fact]
public void TlsPeerCertMapper_produces_subject_and_sha256_values_from_certificate()
{
using var rsa = RSA.Create(2048);
var req = new CertificateRequest("CN=peer", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
using var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(1));
var mapped = TlsPeerCertMapper.FromCertificate(cert);
mapped.Length.ShouldBe(1);
mapped[0].Subject.ShouldContain("CN=peer");
mapped[0].SubjectPKISha256.Length.ShouldBe(64);
mapped[0].CertSha256.Length.ShouldBe(64);
}
[Fact]
public void ConnInfo_json_includes_tls_peer_certs_array()
{
var info = new ConnInfo
{
Cid = 1,
TlsPeerCertSubject = "CN=peer",
TlsPeerCerts =
[
new TLSPeerCert
{
Subject = "CN=peer",
SubjectPKISha256 = new string('c', 64),
CertSha256 = new string('d', 64),
},
],
};
var json = JsonSerializer.Serialize(info);
json.ShouldContain("\"tls_peer_certs\":[");
json.ShouldContain("\"subject_pk_sha256\":");
json.ShouldContain("\"cert_sha256\":");
}
}

View File

@@ -0,0 +1,132 @@
// Ported from golang/nats-server/server/monitor_test.go
// TestMonitorHandleVarz — verify /varz returns valid server identity fields and tracks message stats.
using System.Net;
using System.Net.Http.Json;
using System.Net.Sockets;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server.Monitoring;
using NATS.Server.TestUtilities;
namespace NATS.Server.Monitoring.Tests;
public class VarzParityTests : IAsyncLifetime
{
private readonly NatsServer _server;
private readonly int _natsPort;
private readonly int _monitorPort;
private readonly CancellationTokenSource _cts = new();
private readonly HttpClient _http = new();
public VarzParityTests()
{
_natsPort = TestPortAllocator.GetFreePort();
_monitorPort = TestPortAllocator.GetFreePort();
_server = new NatsServer(
new NatsOptions { Port = _natsPort, MonitorPort = _monitorPort },
NullLoggerFactory.Instance);
}
public async Task InitializeAsync()
{
_ = _server.StartAsync(_cts.Token);
await _server.WaitForReadyAsync();
for (var i = 0; i < 50; i++)
{
try
{
var probe = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/healthz");
if (probe.IsSuccessStatusCode) break;
}
catch (HttpRequestException) { }
await Task.Delay(50);
}
}
public async Task DisposeAsync()
{
_http.Dispose();
await _cts.CancelAsync();
_server.Dispose();
}
/// <summary>
/// Corresponds to Go TestMonitorHandleVarz (first block, mode=0).
/// Verifies the /varz endpoint returns valid JSON containing required server identity fields:
/// server_id, version, now, start, host, port, max_payload, mem, cores.
/// </summary>
[Fact]
public async Task Varz_returns_valid_json_with_server_info()
{
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/varz");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var varz = await response.Content.ReadFromJsonAsync<Varz>();
varz.ShouldNotBeNull();
// server_id must be present and non-empty
varz.Id.ShouldNotBeNullOrEmpty();
// version must be present
varz.Version.ShouldNotBeNullOrEmpty();
// now must be a plausible timestamp (not default DateTime.MinValue)
varz.Now.ShouldBeGreaterThan(DateTime.MinValue);
// start must be within a reasonable window of now
(DateTime.UtcNow - varz.Start).ShouldBeLessThan(TimeSpan.FromSeconds(30));
// host and port must reflect server configuration
varz.Host.ShouldNotBeNullOrEmpty();
varz.Port.ShouldBe(_natsPort);
// max_payload is 1 MB by default (Go reference: defaultMaxPayload = 1MB)
varz.MaxPayload.ShouldBe(1024 * 1024);
// uptime must be non-empty
varz.Uptime.ShouldNotBeNullOrEmpty();
// runtime metrics must be populated
varz.Mem.ShouldBeGreaterThan(0L);
varz.Cores.ShouldBeGreaterThan(0);
}
/// <summary>
/// Corresponds to Go TestMonitorHandleVarz (second block after connecting a client).
/// Verifies /varz correctly tracks connections, total_connections, in_msgs, in_bytes
/// after a client connects, subscribes, and publishes a message.
/// </summary>
[Fact]
public async Task Varz_tracks_connections_and_messages()
{
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _natsPort));
var buf = new byte[4096];
_ = await sock.ReceiveAsync(buf, SocketFlags.None); // consume INFO
// CONNECT + SUB + PUB "hello" (5 bytes) to "test"
var cmd = "CONNECT {}\r\nSUB test 1\r\nPUB test 5\r\nhello\r\n"u8.ToArray();
await sock.SendAsync(cmd, SocketFlags.None);
await Task.Delay(200);
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/varz");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var varz = await response.Content.ReadFromJsonAsync<Varz>();
varz.ShouldNotBeNull();
// At least 1 active connection
varz.Connections.ShouldBeGreaterThanOrEqualTo(1);
// Total connections must have been counted
varz.TotalConnections.ShouldBeGreaterThanOrEqualTo(1UL);
// in_msgs: at least the 1 PUB we sent
varz.InMsgs.ShouldBeGreaterThanOrEqualTo(1L);
// in_bytes: at least 5 bytes ("hello")
varz.InBytes.ShouldBeGreaterThanOrEqualTo(5L);
}
}

View File

@@ -0,0 +1,17 @@
namespace NATS.Server.Monitoring.Tests;
public class VarzSlowConsumerBreakdownTests
{
[Fact]
public async Task Varz_contains_slow_consumer_breakdown_fields()
{
await using var fx = await MonitoringParityFixture.StartAsync();
var varz = await fx.GetVarzAsync();
varz.SlowConsumerStats.ShouldNotBeNull();
varz.SlowConsumerStats.Clients.ShouldBeGreaterThanOrEqualTo((ulong)0);
varz.SlowConsumerStats.Routes.ShouldBeGreaterThanOrEqualTo((ulong)0);
varz.SlowConsumerStats.Gateways.ShouldBeGreaterThanOrEqualTo((ulong)0);
varz.SlowConsumerStats.Leafs.ShouldBeGreaterThanOrEqualTo((ulong)0);
}
}

View File

@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NATS.Client.Core" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="Shouldly" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
<Using Include="Shouldly" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\NATS.Server\NATS.Server.csproj" />
<ProjectReference Include="..\NATS.Server.TestUtilities\NATS.Server.TestUtilities.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,132 @@
using System.Net;
using System.Net.Http.Json;
using System.Net.Sockets;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server.Monitoring;
using NATS.Server.TestUtilities;
namespace NATS.Server.Monitoring.Tests;
public class SubszTests : IAsyncLifetime
{
private readonly NatsServer _server;
private readonly int _natsPort;
private readonly int _monitorPort;
private readonly CancellationTokenSource _cts = new();
private readonly HttpClient _http = new();
public SubszTests()
{
_natsPort = TestPortAllocator.GetFreePort();
_monitorPort = TestPortAllocator.GetFreePort();
_server = new NatsServer(
new NatsOptions { Port = _natsPort, MonitorPort = _monitorPort },
NullLoggerFactory.Instance);
}
public async Task InitializeAsync()
{
_ = _server.StartAsync(_cts.Token);
await _server.WaitForReadyAsync();
for (int i = 0; i < 50; i++)
{
try
{
var resp = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/healthz");
if (resp.IsSuccessStatusCode) break;
}
catch (HttpRequestException) { }
await Task.Delay(50);
}
}
public async Task DisposeAsync()
{
_http.Dispose();
await _cts.CancelAsync();
_server.Dispose();
}
[Fact]
public async Task Subz_returns_empty_when_no_subscriptions()
{
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/subz");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var subz = await response.Content.ReadFromJsonAsync<Subsz>();
subz.ShouldNotBeNull();
subz.NumSubs.ShouldBe(0u);
}
[Fact]
public async Task Subz_returns_count_with_subscriptions()
{
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _natsPort));
using var stream = new NetworkStream(sock);
var buf = new byte[4096];
_ = await stream.ReadAsync(buf);
await stream.WriteAsync("CONNECT {}\r\nSUB foo 1\r\nSUB bar 2\r\nSUB baz.* 3\r\n"u8.ToArray());
await Task.Delay(200);
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/subz");
var subz = await response.Content.ReadFromJsonAsync<Subsz>();
subz.ShouldNotBeNull();
subz.NumSubs.ShouldBeGreaterThanOrEqualTo(3u);
}
[Fact]
public async Task Subz_subs_true_returns_subscription_details()
{
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _natsPort));
using var stream = new NetworkStream(sock);
var buf = new byte[4096];
_ = await stream.ReadAsync(buf);
await stream.WriteAsync("CONNECT {}\r\nSUB foo 1\r\n"u8.ToArray());
await Task.Delay(200);
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/subz?subs=true");
var subz = await response.Content.ReadFromJsonAsync<Subsz>();
subz.ShouldNotBeNull();
subz.Subs.ShouldNotBeEmpty();
subz.Subs.ShouldContain(s => s.Subject == "foo");
}
[Fact]
public async Task Subz_test_subject_filters_matching_subs()
{
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _natsPort));
using var stream = new NetworkStream(sock);
var buf = new byte[4096];
_ = await stream.ReadAsync(buf);
await stream.WriteAsync("CONNECT {}\r\nSUB foo.* 1\r\nSUB bar 2\r\n"u8.ToArray());
await Task.Delay(200);
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/subz?subs=true&test=foo.hello");
var subz = await response.Content.ReadFromJsonAsync<Subsz>();
subz.ShouldNotBeNull();
subz.Subs.ShouldContain(s => s.Subject == "foo.*");
subz.Subs.ShouldNotContain(s => s.Subject == "bar");
}
[Fact]
public async Task Subz_pagination()
{
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _natsPort));
using var stream = new NetworkStream(sock);
var buf = new byte[4096];
_ = await stream.ReadAsync(buf);
await stream.WriteAsync("CONNECT {}\r\nSUB a 1\r\nSUB b 2\r\nSUB c 3\r\n"u8.ToArray());
await Task.Delay(200);
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/subz?subs=true&offset=0&limit=2");
var subz = await response.Content.ReadFromJsonAsync<Subsz>();
subz.ShouldNotBeNull();
subz.Subs.Length.ShouldBe(2);
subz.Total.ShouldBeGreaterThanOrEqualTo(3);
}
}

View File

@@ -0,0 +1,125 @@
using System.Text.Json;
using NATS.Server;
using NATS.Server.Events;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server.TestUtilities;
namespace NATS.Server.Monitoring.Tests;
public class SystemEventsTests
{
[Fact]
public async Task Server_publishes_connect_event_on_client_auth()
{
using var server = CreateTestServer();
_ = server.StartAsync(CancellationToken.None);
await server.WaitForReadyAsync();
var received = new TaskCompletionSource<string>();
server.EventSystem!.SysSubscribe("$SYS.ACCOUNT.*.CONNECT", (sub, client, acc, subject, reply, hdr, msg) =>
{
received.TrySetResult(subject);
});
// Connect a real client
using var sock = new System.Net.Sockets.Socket(
System.Net.Sockets.AddressFamily.InterNetwork,
System.Net.Sockets.SocketType.Stream,
System.Net.Sockets.ProtocolType.Tcp);
await sock.ConnectAsync(System.Net.IPAddress.Loopback, server.Port);
// Read INFO
var buf = new byte[4096];
await sock.ReceiveAsync(buf);
// Send CONNECT
var connect = System.Text.Encoding.ASCII.GetBytes("CONNECT {}\r\n");
await sock.SendAsync(connect);
var result = await received.Task.WaitAsync(TimeSpan.FromSeconds(5));
result.ShouldStartWith("$SYS.ACCOUNT.");
result.ShouldEndWith(".CONNECT");
await server.ShutdownAsync();
}
[Fact]
public async Task Server_publishes_disconnect_event_on_client_close()
{
using var server = CreateTestServer();
_ = server.StartAsync(CancellationToken.None);
await server.WaitForReadyAsync();
var received = new TaskCompletionSource<string>();
server.EventSystem!.SysSubscribe("$SYS.ACCOUNT.*.DISCONNECT", (sub, client, acc, subject, reply, hdr, msg) =>
{
received.TrySetResult(subject);
});
// Connect and then disconnect
using var sock = new System.Net.Sockets.Socket(
System.Net.Sockets.AddressFamily.InterNetwork,
System.Net.Sockets.SocketType.Stream,
System.Net.Sockets.ProtocolType.Tcp);
await sock.ConnectAsync(System.Net.IPAddress.Loopback, server.Port);
var buf = new byte[4096];
await sock.ReceiveAsync(buf);
await sock.SendAsync(System.Text.Encoding.ASCII.GetBytes("CONNECT {}\r\n"));
await Task.Delay(100);
sock.Shutdown(System.Net.Sockets.SocketShutdown.Both);
var result = await received.Task.WaitAsync(TimeSpan.FromSeconds(5));
result.ShouldStartWith("$SYS.ACCOUNT.");
result.ShouldEndWith(".DISCONNECT");
await server.ShutdownAsync();
}
[Fact]
public async Task Server_publishes_statsz_periodically()
{
using var server = CreateTestServer();
_ = server.StartAsync(CancellationToken.None);
await server.WaitForReadyAsync();
var received = new TaskCompletionSource<string>();
server.EventSystem!.SysSubscribe("$SYS.SERVER.*.STATSZ", (sub, client, acc, subject, reply, hdr, msg) =>
{
received.TrySetResult(subject);
});
// Trigger a manual stats publish (don't wait 10s)
server.EventSystem!.PublishServerStats();
var result = await received.Task.WaitAsync(TimeSpan.FromSeconds(5));
result.ShouldContain(".STATSZ");
await server.ShutdownAsync();
}
[Fact]
public async Task Server_publishes_shutdown_event()
{
using var server = CreateTestServer();
_ = server.StartAsync(CancellationToken.None);
await server.WaitForReadyAsync();
var received = new TaskCompletionSource<string>();
server.EventSystem!.SysSubscribe("$SYS.SERVER.*.SHUTDOWN", (sub, client, acc, subject, reply, hdr, msg) =>
{
received.TrySetResult(subject);
});
await server.ShutdownAsync();
var result = await received.Task.WaitAsync(TimeSpan.FromSeconds(5));
result.ShouldContain(".SHUTDOWN");
}
private static NatsServer CreateTestServer()
{
var port = TestPortAllocator.GetFreePort();
return new NatsServer(new NatsOptions { Port = port }, NullLoggerFactory.Instance);
}
}

View File

@@ -0,0 +1,162 @@
using System.Text;
using System.Text.Json;
using NATS.Server;
using NATS.Server.Events;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server.TestUtilities;
namespace NATS.Server.Monitoring.Tests;
public class SystemRequestReplyTests
{
[Fact]
public async Task Varz_request_reply_returns_server_info()
{
using var server = CreateTestServer();
_ = server.StartAsync(CancellationToken.None);
await server.WaitForReadyAsync();
var received = new TaskCompletionSource<byte[]>();
var replySubject = $"_INBOX.test.{Guid.NewGuid():N}";
server.EventSystem!.SysSubscribe(replySubject, (sub, client, acc, subject, reply, hdr, msg) =>
{
received.TrySetResult(msg.ToArray());
});
var reqSubject = string.Format(EventSubjects.ServerReq, server.ServerId, "VARZ");
server.SendInternalMsg(reqSubject, replySubject, null);
var result = await received.Task.WaitAsync(TimeSpan.FromSeconds(5));
var json = Encoding.UTF8.GetString(result);
json.ShouldContain("\"server_id\"");
json.ShouldContain("\"version\"");
json.ShouldContain("\"host\"");
json.ShouldContain("\"port\"");
await server.ShutdownAsync();
}
[Fact]
public async Task Healthz_request_reply_returns_ok()
{
using var server = CreateTestServer();
_ = server.StartAsync(CancellationToken.None);
await server.WaitForReadyAsync();
var received = new TaskCompletionSource<byte[]>();
var replySubject = $"_INBOX.test.{Guid.NewGuid():N}";
server.EventSystem!.SysSubscribe(replySubject, (sub, client, acc, subject, reply, hdr, msg) =>
{
received.TrySetResult(msg.ToArray());
});
var reqSubject = string.Format(EventSubjects.ServerReq, server.ServerId, "HEALTHZ");
server.SendInternalMsg(reqSubject, replySubject, null);
var result = await received.Task.WaitAsync(TimeSpan.FromSeconds(5));
var json = Encoding.UTF8.GetString(result);
json.ShouldContain("ok");
await server.ShutdownAsync();
}
[Fact]
public async Task Subsz_request_reply_returns_subscription_count()
{
using var server = CreateTestServer();
_ = server.StartAsync(CancellationToken.None);
await server.WaitForReadyAsync();
var received = new TaskCompletionSource<byte[]>();
var replySubject = $"_INBOX.test.{Guid.NewGuid():N}";
server.EventSystem!.SysSubscribe(replySubject, (sub, client, acc, subject, reply, hdr, msg) =>
{
received.TrySetResult(msg.ToArray());
});
var reqSubject = string.Format(EventSubjects.ServerReq, server.ServerId, "SUBSZ");
server.SendInternalMsg(reqSubject, replySubject, null);
var result = await received.Task.WaitAsync(TimeSpan.FromSeconds(5));
var json = Encoding.UTF8.GetString(result);
json.ShouldContain("\"num_subscriptions\"");
await server.ShutdownAsync();
}
[Fact]
public async Task Idz_request_reply_returns_server_identity()
{
using var server = CreateTestServer();
_ = server.StartAsync(CancellationToken.None);
await server.WaitForReadyAsync();
var received = new TaskCompletionSource<byte[]>();
var replySubject = $"_INBOX.test.{Guid.NewGuid():N}";
server.EventSystem!.SysSubscribe(replySubject, (sub, client, acc, subject, reply, hdr, msg) =>
{
received.TrySetResult(msg.ToArray());
});
var reqSubject = string.Format(EventSubjects.ServerReq, server.ServerId, "IDZ");
server.SendInternalMsg(reqSubject, replySubject, null);
var result = await received.Task.WaitAsync(TimeSpan.FromSeconds(5));
var json = Encoding.UTF8.GetString(result);
json.ShouldContain("\"server_id\"");
json.ShouldContain("\"server_name\"");
await server.ShutdownAsync();
}
[Fact]
public async Task Ping_varz_responds_via_wildcard_subject()
{
using var server = CreateTestServer();
_ = server.StartAsync(CancellationToken.None);
await server.WaitForReadyAsync();
var received = new TaskCompletionSource<byte[]>();
var replySubject = $"_INBOX.test.{Guid.NewGuid():N}";
server.EventSystem!.SysSubscribe(replySubject, (sub, client, acc, subject, reply, hdr, msg) =>
{
received.TrySetResult(msg.ToArray());
});
var pingSubject = string.Format(EventSubjects.ServerPing, "VARZ");
server.SendInternalMsg(pingSubject, replySubject, null);
var result = await received.Task.WaitAsync(TimeSpan.FromSeconds(5));
var json = Encoding.UTF8.GetString(result);
json.ShouldContain("\"server_id\"");
await server.ShutdownAsync();
}
[Fact]
public async Task Request_without_reply_is_ignored()
{
using var server = CreateTestServer();
_ = server.StartAsync(CancellationToken.None);
await server.WaitForReadyAsync();
// Send a request with no reply subject -- should not crash
var reqSubject = string.Format(EventSubjects.ServerReq, server.ServerId, "VARZ");
server.SendInternalMsg(reqSubject, null, null);
// Give it a moment to process without error
await Task.Delay(200);
// Server should still be running
server.IsShuttingDown.ShouldBeFalse();
await server.ShutdownAsync();
}
private static NatsServer CreateTestServer()
{
var port = TestPortAllocator.GetFreePort();
return new NatsServer(new NatsOptions { Port = port }, NullLoggerFactory.Instance);
}
}