diff --git a/src/NATS.Server/Monitoring/Connz.cs b/src/NATS.Server/Monitoring/Connz.cs index 0043484..2f0f214 100644 --- a/src/NATS.Server/Monitoring/Connz.cs +++ b/src/NATS.Server/Monitoring/Connz.cs @@ -163,6 +163,143 @@ public sealed class SubDetail public ulong Cid { get; set; } } +/// +/// Lightweight connection info record used by the account-scoped filter API. +/// Decoupled from so filter logic can be tested without a running server. +/// Go reference: monitor.go ConnInfo (subset of fields relevant to account filtering). +/// +public sealed record ConnzConnectionInfo( + ulong ClientId, + string RemoteAddress, + string? AccountName, + string? Name, + DateTime ConnectedAt, + long InMsgs, + long OutMsgs, + long InBytes, + long OutBytes); + +/// +/// Paginated result returned by . +/// +public sealed record ConnzFilterResult( + IReadOnlyList Connections, + int Total, + int Offset, + int Limit); + +/// +/// Query-string options for the account-scoped filter API. +/// Parses the ?acc=, ?state=, ?offset=, and ?limit= parameters that the Go server +/// accepts on the /connz endpoint. +/// Go reference: monitor.go ConnzOptions / Connz(). +/// +public sealed class ConnzFilterOptions +{ + public string? AccountFilter { get; init; } + + /// "open", "closed", or "any" (default: "open") + public string? StateFilter { get; init; } + + public int Offset { get; init; } + + public int Limit { get; init; } = 1024; + + /// + /// Parses a raw query string (e.g. "?acc=ACCOUNT&state=open&offset=0&limit=100") + /// into a instance. + /// + public static ConnzFilterOptions Parse(string? queryString) + { + if (string.IsNullOrEmpty(queryString)) + return new ConnzFilterOptions(); + + // Strip leading '?' + var qs = queryString.TrimStart('?'); + + string? accountFilter = null; + string? stateFilter = null; + int offset = 0; + int limit = 1024; + + foreach (var pair in qs.Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + var eqIdx = pair.IndexOf('='); + if (eqIdx < 0) continue; + + var key = Uri.UnescapeDataString(pair[..eqIdx]).ToLowerInvariant(); + var value = Uri.UnescapeDataString(pair[(eqIdx + 1)..]); + + switch (key) + { + case "acc": + accountFilter = value; + break; + case "state": + stateFilter = value.ToLowerInvariant(); + break; + case "offset" when int.TryParse(value, out var o): + offset = o; + break; + case "limit" when int.TryParse(value, out var l): + limit = l; + break; + } + } + + return new ConnzFilterOptions + { + AccountFilter = accountFilter, + StateFilter = stateFilter, + Offset = offset, + Limit = limit, + }; + } +} + +/// +/// Pure filtering helper for collections. +/// Provides account-scoped filtering and pagination that mirror the Go server's +/// /connz ?acc= behavior. +/// Go reference: monitor.go Connz() — account filter branch. +/// +public static class ConnzFilter +{ + /// + /// Filters to only those whose + /// matches + /// using a case-insensitive ordinal comparison. + /// + public static IReadOnlyList FilterByAccount( + IEnumerable connections, + string accountName) + { + return connections + .Where(c => string.Equals(c.AccountName, accountName, StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + + /// + /// Applies all filters specified in and returns a paginated result. + /// + public static ConnzFilterResult ApplyFilters( + IEnumerable connections, + ConnzFilterOptions options) + { + IEnumerable filtered = connections; + + if (!string.IsNullOrEmpty(options.AccountFilter)) + filtered = filtered.Where(c => + string.Equals(c.AccountName, options.AccountFilter, StringComparison.OrdinalIgnoreCase)); + + var list = filtered.ToList(); + var total = list.Count; + var paged = list.Skip(options.Offset).Take(options.Limit).ToList(); + + return new ConnzFilterResult(paged, total, options.Offset, options.Limit); + } +} + /// /// Sort options for connection listing. /// Corresponds to Go server/monitor_sort_opts.go SortOpt type. diff --git a/tests/NATS.Server.Tests/Monitoring/ConnzAccountFilterTests.cs b/tests/NATS.Server.Tests/Monitoring/ConnzAccountFilterTests.cs new file mode 100644 index 0000000..786069c --- /dev/null +++ b/tests/NATS.Server.Tests/Monitoring/ConnzAccountFilterTests.cs @@ -0,0 +1,190 @@ +using NATS.Server.Monitoring; + +namespace NATS.Server.Tests.Monitoring; + +/// +/// 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. +/// +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); + } +}