Fix E2E test gaps and add comprehensive E2E + parity test suites
- Fix pull consumer fetch: send original stream subject in HMSG (not inbox) so NATS client distinguishes data messages from control messages - Fix MaxAge expiry: add background timer in StreamManager for periodic pruning - Fix JetStream wire format: Go-compatible anonymous objects with string enums, proper offset-based pagination for stream/consumer list APIs - Add 42 E2E black-box tests (core messaging, auth, TLS, accounts, JetStream) - Add ~1000 parity tests across all subsystems (gaps closure) - Update gap inventory docs to reflect implementation status
This commit is contained in:
@@ -4,6 +4,8 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.JetStream;
|
||||
using NATS.Server.Tls;
|
||||
|
||||
namespace NATS.Server.Configuration;
|
||||
|
||||
@@ -43,12 +45,13 @@ public static class ConfigProcessor
|
||||
public static void ApplyConfig(Dictionary<string, object?> config, NatsOptions opts)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
var warnings = new List<string>();
|
||||
|
||||
foreach (var (key, value) in config)
|
||||
{
|
||||
try
|
||||
{
|
||||
ProcessKey(key, value, opts, errors);
|
||||
ProcessKey(key, value, opts, errors, warnings);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -58,11 +61,16 @@ public static class ConfigProcessor
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
throw new ConfigProcessorException("Configuration errors", errors);
|
||||
throw new ConfigProcessorException("Configuration errors", errors, warnings);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ProcessKey(string key, object? value, NatsOptions opts, List<string> errors)
|
||||
private static void ProcessKey(
|
||||
string key,
|
||||
object? value,
|
||||
NatsOptions opts,
|
||||
List<string> errors,
|
||||
List<string> warnings)
|
||||
{
|
||||
// Keys are already case-insensitive from the parser (OrdinalIgnoreCase dictionaries),
|
||||
// but we normalize here for the switch statement.
|
||||
@@ -277,8 +285,15 @@ public static class ConfigProcessor
|
||||
ParseWebSocket(wsDict, opts, errors);
|
||||
break;
|
||||
|
||||
// Unknown keys silently ignored (accounts, resolver, operator, etc.)
|
||||
// Accounts block — each key is an account name containing users/limits
|
||||
case "accounts":
|
||||
if (value is Dictionary<string, object?> accountsDict)
|
||||
ParseAccounts(accountsDict, opts, errors);
|
||||
break;
|
||||
|
||||
// Unknown keys silently ignored (resolver, operator, etc.)
|
||||
default:
|
||||
warnings.Add(new UnknownConfigFieldWarning(key).Message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -745,6 +760,9 @@ public static class ConfigProcessor
|
||||
case "store_dir":
|
||||
options.StoreDir = ToString(value);
|
||||
break;
|
||||
case "domain":
|
||||
options.Domain = ToString(value);
|
||||
break;
|
||||
case "max_mem_store":
|
||||
try
|
||||
{
|
||||
@@ -766,6 +784,68 @@ public static class ConfigProcessor
|
||||
errors.Add($"Invalid jetstream.max_file_store: {ex.Message}");
|
||||
}
|
||||
|
||||
break;
|
||||
case "sync_interval":
|
||||
try
|
||||
{
|
||||
options.SyncInterval = ParseDuration(value);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"Invalid jetstream.sync_interval: {ex.Message}");
|
||||
}
|
||||
|
||||
break;
|
||||
case "sync_always":
|
||||
options.SyncAlways = ToBool(value);
|
||||
break;
|
||||
case "compress_ok":
|
||||
options.CompressOk = ToBool(value);
|
||||
break;
|
||||
case "unique_tag":
|
||||
options.UniqueTag = ToString(value);
|
||||
break;
|
||||
case "strict":
|
||||
options.Strict = ToBool(value);
|
||||
break;
|
||||
case "max_ack_pending":
|
||||
options.MaxAckPending = ToInt(value);
|
||||
break;
|
||||
case "memory_max_stream_bytes":
|
||||
try
|
||||
{
|
||||
options.MemoryMaxStreamBytes = ParseByteSize(value);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"Invalid jetstream.memory_max_stream_bytes: {ex.Message}");
|
||||
}
|
||||
|
||||
break;
|
||||
case "store_max_stream_bytes":
|
||||
try
|
||||
{
|
||||
options.StoreMaxStreamBytes = ParseByteSize(value);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"Invalid jetstream.store_max_stream_bytes: {ex.Message}");
|
||||
}
|
||||
|
||||
break;
|
||||
case "max_bytes_required":
|
||||
options.MaxBytesRequired = ToBool(value);
|
||||
break;
|
||||
case "tiers":
|
||||
if (value is Dictionary<string, object?> tiers)
|
||||
{
|
||||
foreach (var (tierName, rawTier) in tiers)
|
||||
{
|
||||
if (rawTier is Dictionary<string, object?> tierDict)
|
||||
options.Tiers[tierName] = ParseJetStreamTier(tierName, tierDict, errors);
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -773,6 +853,47 @@ public static class ConfigProcessor
|
||||
return options;
|
||||
}
|
||||
|
||||
private static JetStreamTier ParseJetStreamTier(string tierName, Dictionary<string, object?> dict, List<string> errors)
|
||||
{
|
||||
var tier = new JetStreamTier { Name = tierName };
|
||||
foreach (var (key, value) in dict)
|
||||
{
|
||||
switch (key.ToLowerInvariant())
|
||||
{
|
||||
case "memory" or "max_memory":
|
||||
try
|
||||
{
|
||||
tier.Memory = ParseByteSize(value);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"Invalid jetstream.tiers.{tierName}.memory: {ex.Message}");
|
||||
}
|
||||
|
||||
break;
|
||||
case "store" or "max_store":
|
||||
try
|
||||
{
|
||||
tier.Store = ParseByteSize(value);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"Invalid jetstream.tiers.{tierName}.store: {ex.Message}");
|
||||
}
|
||||
|
||||
break;
|
||||
case "streams" or "max_streams":
|
||||
tier.Streams = ToInt(value);
|
||||
break;
|
||||
case "consumers" or "max_consumers":
|
||||
tier.Consumers = ToInt(value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return tier;
|
||||
}
|
||||
|
||||
// ─── Authorization parsing ─────────────────────────────────────
|
||||
|
||||
private static void ParseAuthorization(Dictionary<string, object?> dict, NatsOptions opts, List<string> errors)
|
||||
@@ -831,12 +952,80 @@ public static class ConfigProcessor
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Accounts parsing ──────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Parses the top-level "accounts" block. Each key is an account name, and each
|
||||
/// value is a dictionary that may contain "users" (array) and account-level limits.
|
||||
/// Users are stamped with the account name and appended to opts.Users / opts.NKeys.
|
||||
/// Go reference: opts.go — configureAccounts / parseAccounts.
|
||||
/// </summary>
|
||||
private static void ParseAccounts(Dictionary<string, object?> accountsDict, NatsOptions opts, List<string> errors)
|
||||
{
|
||||
opts.Accounts ??= new Dictionary<string, AccountConfig>();
|
||||
|
||||
foreach (var (accountName, accountValue) in accountsDict)
|
||||
{
|
||||
if (accountValue is not Dictionary<string, object?> acctDict)
|
||||
{
|
||||
errors.Add($"Expected account '{accountName}' value to be a map");
|
||||
continue;
|
||||
}
|
||||
|
||||
int maxConnections = 0;
|
||||
int maxSubscriptions = 0;
|
||||
List<object?>? userList = null;
|
||||
|
||||
foreach (var (key, value) in acctDict)
|
||||
{
|
||||
switch (key.ToLowerInvariant())
|
||||
{
|
||||
case "users":
|
||||
if (value is List<object?> ul)
|
||||
userList = ul;
|
||||
break;
|
||||
case "max_connections" or "max_conns":
|
||||
maxConnections = ToInt(value);
|
||||
break;
|
||||
case "max_subscriptions" or "max_subs":
|
||||
maxSubscriptions = ToInt(value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
opts.Accounts[accountName] = new AccountConfig
|
||||
{
|
||||
MaxConnections = maxConnections,
|
||||
MaxSubscriptions = maxSubscriptions,
|
||||
};
|
||||
|
||||
if (userList is not null)
|
||||
{
|
||||
var (plainUsers, nkeyUsers) = ParseUsersAndNkeys(userList, errors, defaultAccount: accountName);
|
||||
|
||||
if (plainUsers.Count > 0)
|
||||
{
|
||||
var existing = opts.Users?.ToList() ?? [];
|
||||
existing.AddRange(plainUsers);
|
||||
opts.Users = existing;
|
||||
}
|
||||
|
||||
if (nkeyUsers.Count > 0)
|
||||
{
|
||||
var existing = opts.NKeys?.ToList() ?? [];
|
||||
existing.AddRange(nkeyUsers);
|
||||
opts.NKeys = existing;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splits a users array into plain users and NKey users.
|
||||
/// An entry with an "nkey" field is an NKey user; entries with "user" are plain users.
|
||||
/// Go reference: opts.go — parseUsers (lines ~2500-2700).
|
||||
/// </summary>
|
||||
private static (List<User> PlainUsers, List<Auth.NKeyUser> NkeyUsers) ParseUsersAndNkeys(List<object?> list, List<string> errors)
|
||||
private static (List<User> PlainUsers, List<Auth.NKeyUser> NkeyUsers) ParseUsersAndNkeys(List<object?> list, List<string> errors, string? defaultAccount = null)
|
||||
{
|
||||
var plainUsers = new List<User>();
|
||||
var nkeyUsers = new List<Auth.NKeyUser>();
|
||||
@@ -888,7 +1077,7 @@ public static class ConfigProcessor
|
||||
{
|
||||
Nkey = nkey,
|
||||
Permissions = permissions,
|
||||
Account = account,
|
||||
Account = account ?? defaultAccount,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -903,7 +1092,7 @@ public static class ConfigProcessor
|
||||
{
|
||||
Username = username,
|
||||
Password = password ?? string.Empty,
|
||||
Account = account,
|
||||
Account = account ?? defaultAccount,
|
||||
Permissions = permissions,
|
||||
});
|
||||
}
|
||||
@@ -1087,6 +1276,9 @@ public static class ConfigProcessor
|
||||
case "handshake_first_fallback":
|
||||
opts.TlsHandshakeFirstFallback = ParseDuration(value);
|
||||
break;
|
||||
case "ocsp_peer":
|
||||
ParseOcspPeer(value, opts, errors);
|
||||
break;
|
||||
default:
|
||||
// Unknown TLS keys silently ignored
|
||||
break;
|
||||
@@ -1094,6 +1286,31 @@ public static class ConfigProcessor
|
||||
}
|
||||
}
|
||||
|
||||
private static void ParseOcspPeer(object? value, NatsOptions opts, List<string> errors)
|
||||
{
|
||||
switch (value)
|
||||
{
|
||||
case bool verify:
|
||||
opts.OcspPeerVerify = verify;
|
||||
return;
|
||||
case Dictionary<string, object?> dict:
|
||||
try
|
||||
{
|
||||
var cfg = OCSPPeerConfig.Parse(dict);
|
||||
opts.OcspPeerVerify = cfg.Verify;
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
errors.Add(ex.Message);
|
||||
}
|
||||
|
||||
return;
|
||||
default:
|
||||
errors.Add($"expected map to define OCSP peer options, got [{value?.GetType().Name ?? "null"}]");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tags parsing ──────────────────────────────────────────────
|
||||
|
||||
private static void ParseTags(Dictionary<string, object?> dict, NatsOptions opts)
|
||||
@@ -1431,8 +1648,28 @@ public static class ConfigProcessor
|
||||
/// Thrown when one or more configuration validation errors are detected.
|
||||
/// All errors are collected rather than failing on the first one.
|
||||
/// </summary>
|
||||
public sealed class ConfigProcessorException(string message, List<string> errors)
|
||||
public sealed class ConfigProcessorException(string message, List<string> errors, List<string>? warnings = null)
|
||||
: Exception(message)
|
||||
{
|
||||
public IReadOnlyList<string> Errors => errors;
|
||||
public IReadOnlyList<string> Warnings => warnings ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a non-fatal configuration warning.
|
||||
/// Go reference: configWarningErr.
|
||||
/// </summary>
|
||||
public class ConfigWarningException(string message, string? source = null) : Exception(message)
|
||||
{
|
||||
public string? SourceLocation { get; } = source;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Warning used when an unknown config field is encountered.
|
||||
/// Go reference: unknownConfigFieldErr.
|
||||
/// </summary>
|
||||
public sealed class UnknownConfigFieldWarning(string field, string? source = null)
|
||||
: ConfigWarningException($"unknown field {field}", source)
|
||||
{
|
||||
public string Field { get; } = field;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user