feat: add account-scoped filtering to /connz (Gap 10.2)
Add ConnzConnectionInfo, ConnzFilterResult, ConnzFilterOptions, and ConnzFilter static class to Connz.cs, providing a pure unit-testable layer for account-scoped filtering and pagination that mirrors the Go server's /connz ?acc= query-parameter behaviour. Ten new tests in ConnzAccountFilterTests.cs cover FilterByAccount (match, no-match, case-insensitive), ConnzFilterOptions.Parse (acc param, defaults, offset/limit), and ApplyFilters (account filter, offset, limit, no-filter pass-through).
This commit is contained in:
@@ -163,6 +163,143 @@ public sealed class SubDetail
|
|||||||
public ulong Cid { get; set; }
|
public ulong Cid { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lightweight connection info record used by the account-scoped filter API.
|
||||||
|
/// Decoupled from <see cref="ConnInfo"/> so filter logic can be tested without a running server.
|
||||||
|
/// Go reference: monitor.go ConnInfo (subset of fields relevant to account filtering).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ConnzConnectionInfo(
|
||||||
|
ulong ClientId,
|
||||||
|
string RemoteAddress,
|
||||||
|
string? AccountName,
|
||||||
|
string? Name,
|
||||||
|
DateTime ConnectedAt,
|
||||||
|
long InMsgs,
|
||||||
|
long OutMsgs,
|
||||||
|
long InBytes,
|
||||||
|
long OutBytes);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Paginated result returned by <see cref="ConnzFilter.ApplyFilters"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ConnzFilterResult(
|
||||||
|
IReadOnlyList<ConnzConnectionInfo> Connections,
|
||||||
|
int Total,
|
||||||
|
int Offset,
|
||||||
|
int Limit);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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().
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ConnzFilterOptions
|
||||||
|
{
|
||||||
|
public string? AccountFilter { get; init; }
|
||||||
|
|
||||||
|
/// <summary>"open", "closed", or "any" (default: "open")</summary>
|
||||||
|
public string? StateFilter { get; init; }
|
||||||
|
|
||||||
|
public int Offset { get; init; }
|
||||||
|
|
||||||
|
public int Limit { get; init; } = 1024;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a raw query string (e.g. "?acc=ACCOUNT&state=open&offset=0&limit=100")
|
||||||
|
/// into a <see cref="ConnzFilterOptions"/> instance.
|
||||||
|
/// </summary>
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure filtering helper for <see cref="ConnzConnectionInfo"/> collections.
|
||||||
|
/// Provides account-scoped filtering and pagination that mirror the Go server's
|
||||||
|
/// /connz ?acc= behavior.
|
||||||
|
/// Go reference: monitor.go Connz() — account filter branch.
|
||||||
|
/// </summary>
|
||||||
|
public static class ConnzFilter
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Filters <paramref name="connections"/> to only those whose
|
||||||
|
/// <see cref="ConnzConnectionInfo.AccountName"/> matches <paramref name="accountName"/>
|
||||||
|
/// using a case-insensitive ordinal comparison.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<ConnzConnectionInfo> FilterByAccount(
|
||||||
|
IEnumerable<ConnzConnectionInfo> connections,
|
||||||
|
string accountName)
|
||||||
|
{
|
||||||
|
return connections
|
||||||
|
.Where(c => string.Equals(c.AccountName, accountName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies all filters specified in <paramref name="options"/> and returns a paginated result.
|
||||||
|
/// </summary>
|
||||||
|
public static ConnzFilterResult ApplyFilters(
|
||||||
|
IEnumerable<ConnzConnectionInfo> connections,
|
||||||
|
ConnzFilterOptions options)
|
||||||
|
{
|
||||||
|
IEnumerable<ConnzConnectionInfo> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sort options for connection listing.
|
/// Sort options for connection listing.
|
||||||
/// Corresponds to Go server/monitor_sort_opts.go SortOpt type.
|
/// Corresponds to Go server/monitor_sort_opts.go SortOpt type.
|
||||||
|
|||||||
190
tests/NATS.Server.Tests/Monitoring/ConnzAccountFilterTests.cs
Normal file
190
tests/NATS.Server.Tests/Monitoring/ConnzAccountFilterTests.cs
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
using NATS.Server.Monitoring;
|
||||||
|
|
||||||
|
namespace NATS.Server.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user