feat: add auth error event publication (Gap 10.5)

Add SendAuthErrorEvent, SendConnectEvent, SendDisconnectEvent to
InternalEventSystem, plus AuthErrorEventCount counter and the three
companion detail record types (AuthErrorDetail, ConnectEventDetail,
DisconnectEventDetail). 10 new tests in AuthErrorEventTests all pass.
This commit is contained in:
Joseph Doherty
2026-02-25 13:12:52 -05:00
parent 10ac904b5c
commit 4b9384dfcf
2 changed files with 417 additions and 0 deletions

View File

@@ -38,6 +38,43 @@ public sealed class InternalSystemMessage
public required SystemMessageHandler Callback { get; init; }
}
/// <summary>
/// Detail payload for an auth error advisory.
/// Provides the caller-supplied fields used to populate <see cref="AuthErrorEventMsg"/>.
/// Go reference: server.go sendAuthErrorEvent — arguments passed when constructing the advisory.
/// </summary>
public sealed record AuthErrorDetail(
ulong ClientId,
string RemoteAddress,
string? AccountName,
string? UserName,
string Reason,
DateTime OccurredAt);
/// <summary>
/// Detail payload for a client connect advisory.
/// Provides the caller-supplied fields used to populate <see cref="ConnectEventMsg"/>.
/// Go reference: events.go sendConnect / postConnectEvent.
/// </summary>
public sealed record ConnectEventDetail(
ulong ClientId,
string RemoteAddress,
string? AccountName,
string? UserName,
DateTime ConnectedAt);
/// <summary>
/// Detail payload for a client disconnect advisory.
/// Provides the caller-supplied fields used to populate <see cref="DisconnectEventMsg"/>.
/// Go reference: events.go sendDisconnect / postDisconnectEvent.
/// </summary>
public sealed record DisconnectEventDetail(
ulong ClientId,
string RemoteAddress,
string? AccountName,
string Reason,
DateTime DisconnectedAt);
/// <summary>
/// Manages the server's internal event system with Channel-based send/receive loops.
/// Maps to Go's internal struct in events.go:124-147 and the goroutines
@@ -59,11 +96,18 @@ public sealed class InternalEventSystem : IAsyncDisposable
private ulong _sequence;
private int _subscriptionId;
private readonly ConcurrentDictionary<string, SystemMessageHandler> _callbacks = new();
private long _authErrorEventCount;
public Account SystemAccount { get; }
public InternalClient SystemClient { get; }
public string ServerHash { get; }
/// <summary>
/// Number of auth error events sent since this instance was created.
/// Go reference: server stats tracking of auth error advisories.
/// </summary>
public long AuthErrorEventCount => Interlocked.Read(ref _authErrorEventCount);
public InternalEventSystem(Account systemAccount, InternalClient systemClient, string serverName, ILogger logger)
{
_logger = logger;
@@ -225,6 +269,85 @@ public sealed class InternalEventSystem : IAsyncDisposable
/// </summary>
public ulong NextSequence() => Interlocked.Increment(ref _sequence);
/// <summary>
/// Publishes a client auth-error advisory to $SYS.SERVER.{id}.CLIENT.AUTH.ERR.
/// Increments <see cref="AuthErrorEventCount"/> each time it is called.
/// Go reference: events.go:2631 sendAuthErrorEvent.
/// </summary>
public void SendAuthErrorEvent(string serverId, AuthErrorDetail detail)
{
var subject = string.Format(EventSubjects.AuthError, serverId);
var msg = new AuthErrorEventMsg
{
Id = Guid.NewGuid().ToString("N"),
Time = detail.OccurredAt,
Server = _server?.BuildEventServerInfo() ?? new EventServerInfo { Id = serverId },
Client = new EventClientInfo
{
Id = detail.ClientId,
Host = detail.RemoteAddress,
Account = detail.AccountName,
User = detail.UserName,
},
Reason = detail.Reason,
};
Interlocked.Increment(ref _authErrorEventCount);
Enqueue(new PublishMessage { Subject = subject, Body = msg });
}
/// <summary>
/// Publishes a client connect advisory to $SYS.ACCOUNT.{account}.CONNECT.
/// Go reference: events.go postConnectEvent / sendConnect.
/// </summary>
public void SendConnectEvent(string serverId, ConnectEventDetail detail)
{
var accountName = detail.AccountName ?? "$G";
var subject = string.Format(EventSubjects.ConnectEvent, accountName);
var msg = new ConnectEventMsg
{
Id = Guid.NewGuid().ToString("N"),
Time = detail.ConnectedAt,
Server = _server?.BuildEventServerInfo() ?? new EventServerInfo { Id = serverId },
Client = new EventClientInfo
{
Id = detail.ClientId,
Host = detail.RemoteAddress,
Account = detail.AccountName,
User = detail.UserName,
Start = detail.ConnectedAt,
},
};
Enqueue(new PublishMessage { Subject = subject, Body = msg });
}
/// <summary>
/// Publishes a client disconnect advisory to $SYS.ACCOUNT.{account}.DISCONNECT.
/// Go reference: events.go postDisconnectEvent / sendDisconnect.
/// </summary>
public void SendDisconnectEvent(string serverId, DisconnectEventDetail detail)
{
var accountName = detail.AccountName ?? "$G";
var subject = string.Format(EventSubjects.DisconnectEvent, accountName);
var msg = new DisconnectEventMsg
{
Id = Guid.NewGuid().ToString("N"),
Time = detail.DisconnectedAt,
Server = _server?.BuildEventServerInfo() ?? new EventServerInfo { Id = serverId },
Client = new EventClientInfo
{
Id = detail.ClientId,
Host = detail.RemoteAddress,
Account = detail.AccountName,
Stop = detail.DisconnectedAt,
},
Reason = detail.Reason,
};
Enqueue(new PublishMessage { Subject = subject, Body = msg });
}
/// <summary>
/// Enqueue an internal message for publishing through the send loop.
/// </summary>

View File

@@ -0,0 +1,294 @@
// 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;
namespace NATS.Server.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 = 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();
}
private static int GetFreePort()
{
using var sock = new System.Net.Sockets.Socket(
System.Net.Sockets.AddressFamily.InterNetwork,
System.Net.Sockets.SocketType.Stream,
System.Net.Sockets.ProtocolType.Tcp);
sock.Bind(new System.Net.IPEndPoint(System.Net.IPAddress.Loopback, 0));
return ((System.Net.IPEndPoint)sock.LocalEndPoint!).Port;
}
// ========================================================================
// 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);
}
}