From 8a2ded8e48dffaef4a3dca8cecc8458bcba0ba01 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 23 Feb 2026 04:47:54 -0500 Subject: [PATCH] feat: add config processor mapping parsed config to NatsOptions Port of Go server/opts.go processConfigFileLine switch. Maps parsed NATS config dictionaries to NatsOptions fields including: - Core options (port, host, server_name, limits, ping, write_deadline) - Logging (debug, trace, logfile, log rotation) - Authorization (single user, users array with permissions) - TLS (cert/key/ca, verify, pinned_certs, handshake_first) - Monitoring (http_port, https_port, http/https listen, base_path) - Lifecycle (lame_duck_duration/grace_period) - Server tags, file paths, system account options Includes error collection (not fail-fast), duration parsing (ms/s/m/h strings and numeric seconds), host:port listen parsing, and 56 tests covering all config sections plus validation edge cases. --- .../Configuration/ConfigProcessor.cs | 685 ++++++++++++++++++ .../NATS.Server.Tests/ConfigProcessorTests.cs | 504 +++++++++++++ .../NATS.Server.Tests.csproj | 4 + tests/NATS.Server.Tests/TestData/auth.conf | 11 + tests/NATS.Server.Tests/TestData/basic.conf | 19 + tests/NATS.Server.Tests/TestData/full.conf | 57 ++ tests/NATS.Server.Tests/TestData/tls.conf | 12 + 7 files changed, 1292 insertions(+) create mode 100644 src/NATS.Server/Configuration/ConfigProcessor.cs create mode 100644 tests/NATS.Server.Tests/ConfigProcessorTests.cs create mode 100644 tests/NATS.Server.Tests/TestData/auth.conf create mode 100644 tests/NATS.Server.Tests/TestData/basic.conf create mode 100644 tests/NATS.Server.Tests/TestData/full.conf create mode 100644 tests/NATS.Server.Tests/TestData/tls.conf diff --git a/src/NATS.Server/Configuration/ConfigProcessor.cs b/src/NATS.Server/Configuration/ConfigProcessor.cs new file mode 100644 index 0000000..88b36ae --- /dev/null +++ b/src/NATS.Server/Configuration/ConfigProcessor.cs @@ -0,0 +1,685 @@ +// Port of Go server/opts.go processConfigFileLine — maps parsed config dictionaries +// to NatsOptions. Reference: golang/nats-server/server/opts.go lines 1050-1400. + +using System.Globalization; +using System.Text.RegularExpressions; +using NATS.Server.Auth; + +namespace NATS.Server.Configuration; + +/// +/// Maps a parsed NATS configuration dictionary (produced by ) +/// into a fully populated instance. Collects all validation +/// errors rather than failing on the first one. +/// +public static class ConfigProcessor +{ + /// + /// Parses a configuration file and returns the populated options. + /// + public static NatsOptions ProcessConfigFile(string filePath) + { + var config = NatsConfParser.ParseFile(filePath); + var opts = new NatsOptions { ConfigFile = filePath }; + ApplyConfig(config, opts); + return opts; + } + + /// + /// Parses configuration text (not from a file) and returns the populated options. + /// + public static NatsOptions ProcessConfig(string configText) + { + var config = NatsConfParser.Parse(configText); + var opts = new NatsOptions(); + ApplyConfig(config, opts); + return opts; + } + + /// + /// Applies a parsed configuration dictionary to existing options. + /// Throws if any validation errors are collected. + /// + public static void ApplyConfig(Dictionary config, NatsOptions opts) + { + var errors = new List(); + + foreach (var (key, value) in config) + { + try + { + ProcessKey(key, value, opts, errors); + } + catch (Exception ex) + { + errors.Add($"Error processing '{key}': {ex.Message}"); + } + } + + if (errors.Count > 0) + { + throw new ConfigProcessorException("Configuration errors", errors); + } + } + + private static void ProcessKey(string key, object? value, NatsOptions opts, List errors) + { + // Keys are already case-insensitive from the parser (OrdinalIgnoreCase dictionaries), + // but we normalize here for the switch statement. + switch (key.ToLowerInvariant()) + { + case "listen": + ParseListen(value, opts); + break; + case "port": + opts.Port = ToInt(value); + break; + case "host" or "net": + opts.Host = ToString(value); + break; + case "server_name": + var name = ToString(value); + if (name.Contains(' ')) + errors.Add("server_name cannot contain spaces"); + else + opts.ServerName = name; + break; + case "client_advertise": + opts.ClientAdvertise = ToString(value); + break; + + // Logging + case "debug": + opts.Debug = ToBool(value); + break; + case "trace": + opts.Trace = ToBool(value); + break; + case "trace_verbose": + opts.TraceVerbose = ToBool(value); + if (opts.TraceVerbose) + opts.Trace = true; + break; + case "logtime": + opts.Logtime = ToBool(value); + break; + case "logtime_utc": + opts.LogtimeUTC = ToBool(value); + break; + case "logfile" or "log_file": + opts.LogFile = ToString(value); + break; + case "log_size_limit": + opts.LogSizeLimit = ToLong(value); + break; + case "log_max_num": + opts.LogMaxFiles = ToInt(value); + break; + case "syslog": + opts.Syslog = ToBool(value); + break; + case "remote_syslog": + opts.RemoteSyslog = ToString(value); + break; + + // Limits + case "max_payload": + opts.MaxPayload = ToInt(value); + break; + case "max_control_line": + opts.MaxControlLine = ToInt(value); + break; + case "max_connections" or "max_conn": + opts.MaxConnections = ToInt(value); + break; + case "max_pending": + opts.MaxPending = ToLong(value); + break; + case "max_subs" or "max_subscriptions": + opts.MaxSubs = ToInt(value); + break; + case "max_sub_tokens" or "max_subscription_tokens": + var tokens = ToInt(value); + if (tokens > 256) + errors.Add("max_sub_tokens cannot exceed 256"); + else + opts.MaxSubTokens = tokens; + break; + case "max_traced_msg_len": + opts.MaxTracedMsgLen = ToInt(value); + break; + case "max_closed_clients": + opts.MaxClosedClients = ToInt(value); + break; + case "disable_sublist_cache" or "no_sublist_cache": + opts.DisableSublistCache = ToBool(value); + break; + case "write_deadline": + opts.WriteDeadline = ParseDuration(value); + break; + + // Ping + case "ping_interval": + opts.PingInterval = ParseDuration(value); + break; + case "ping_max" or "ping_max_out": + opts.MaxPingsOut = ToInt(value); + break; + + // Monitoring + case "http_port" or "monitor_port": + opts.MonitorPort = ToInt(value); + break; + case "https_port": + opts.MonitorHttpsPort = ToInt(value); + break; + case "http": + ParseMonitorListen(value, opts, isHttps: false); + break; + case "https": + ParseMonitorListen(value, opts, isHttps: true); + break; + case "http_base_path": + opts.MonitorBasePath = ToString(value); + break; + + // Lifecycle + case "lame_duck_duration": + opts.LameDuckDuration = ParseDuration(value); + break; + case "lame_duck_grace_period": + opts.LameDuckGracePeriod = ParseDuration(value); + break; + + // Files + case "pidfile" or "pid_file": + opts.PidFile = ToString(value); + break; + case "ports_file_dir": + opts.PortsFileDir = ToString(value); + break; + + // Auth + case "authorization": + if (value is Dictionary authDict) + ParseAuthorization(authDict, opts, errors); + break; + case "no_auth_user": + opts.NoAuthUser = ToString(value); + break; + + // TLS + case "tls": + if (value is Dictionary tlsDict) + ParseTls(tlsDict, opts, errors); + break; + case "allow_non_tls": + opts.AllowNonTls = ToBool(value); + break; + + // Tags + case "server_tags": + if (value is Dictionary tagsDict) + ParseTags(tagsDict, opts); + break; + + // Profiling + case "prof_port": + opts.ProfPort = ToInt(value); + break; + + // System account + case "system_account": + opts.SystemAccount = ToString(value); + break; + case "no_system_account": + opts.NoSystemAccount = ToBool(value); + break; + case "no_header_support": + opts.NoHeaderSupport = ToBool(value); + break; + case "connect_error_reports": + opts.ConnectErrorReports = ToInt(value); + break; + case "reconnect_error_reports": + opts.ReconnectErrorReports = ToInt(value); + break; + + // Unknown keys silently ignored (cluster, jetstream, gateway, leafnode, etc.) + default: + break; + } + } + + // ─── Listen parsing ──────────────────────────────────────────── + + /// + /// Parses a "listen" value that can be: + /// + /// ":4222" — port only + /// "0.0.0.0:4222" — host + port + /// "4222" — bare number (port only) + /// 4222 — integer (port only) + /// + /// + private static void ParseListen(object? value, NatsOptions opts) + { + var (host, port) = ParseHostPort(value); + if (host is not null) + opts.Host = host; + if (port is not null) + opts.Port = port.Value; + } + + /// + /// Parses a monitor listen value. For "http" the port goes to MonitorPort; + /// for "https" the port goes to MonitorHttpsPort. + /// + private static void ParseMonitorListen(object? value, NatsOptions opts, bool isHttps) + { + var (host, port) = ParseHostPort(value); + if (host is not null) + opts.MonitorHost = host; + if (port is not null) + { + if (isHttps) + opts.MonitorHttpsPort = port.Value; + else + opts.MonitorPort = port.Value; + } + } + + /// + /// Shared host:port parsing logic. + /// + private static (string? Host, int? Port) ParseHostPort(object? value) + { + if (value is long l) + return (null, (int)l); + + var str = ToString(value); + + // Try bare integer + if (int.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out var barePort)) + return (null, barePort); + + // Check for host:port + var colonIdx = str.LastIndexOf(':'); + if (colonIdx >= 0) + { + var hostPart = str[..colonIdx]; + var portPart = str[(colonIdx + 1)..]; + if (int.TryParse(portPart, NumberStyles.Integer, CultureInfo.InvariantCulture, out var p)) + { + var host = hostPart.Length > 0 ? hostPart : null; + return (host, p); + } + } + + throw new FormatException($"Cannot parse listen value: '{str}'"); + } + + // ─── Duration parsing ────────────────────────────────────────── + + /// + /// Parses a duration value. Accepts: + /// + /// A string with unit suffix: "30s", "2m", "1h", "500ms" + /// A number (long/double) treated as seconds + /// + /// + internal static TimeSpan ParseDuration(object? value) + { + return value switch + { + long seconds => TimeSpan.FromSeconds(seconds), + double seconds => TimeSpan.FromSeconds(seconds), + string s => ParseDurationString(s), + _ => throw new FormatException($"Cannot parse duration from {value?.GetType().Name ?? "null"}"), + }; + } + + private static readonly Regex DurationPattern = new( + @"^(-?\d+(?:\.\d+)?)\s*(ms|s|m|h)$", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + private static TimeSpan ParseDurationString(string s) + { + var match = DurationPattern.Match(s); + if (!match.Success) + throw new FormatException($"Cannot parse duration: '{s}'"); + + var amount = double.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture); + var unit = match.Groups[2].Value.ToLowerInvariant(); + + return unit switch + { + "ms" => TimeSpan.FromMilliseconds(amount), + "s" => TimeSpan.FromSeconds(amount), + "m" => TimeSpan.FromMinutes(amount), + "h" => TimeSpan.FromHours(amount), + _ => throw new FormatException($"Unknown duration unit: '{unit}'"), + }; + } + + // ─── Authorization parsing ───────────────────────────────────── + + private static void ParseAuthorization(Dictionary dict, NatsOptions opts, List errors) + { + foreach (var (key, value) in dict) + { + switch (key.ToLowerInvariant()) + { + case "user" or "username": + opts.Username = ToString(value); + break; + case "pass" or "password": + opts.Password = ToString(value); + break; + case "token": + opts.Authorization = ToString(value); + break; + case "timeout": + opts.AuthTimeout = value switch + { + long l => TimeSpan.FromSeconds(l), + double d => TimeSpan.FromSeconds(d), + string s => ParseDuration(s), + _ => throw new FormatException($"Invalid auth timeout type: {value?.GetType().Name}"), + }; + break; + case "users": + if (value is List userList) + opts.Users = ParseUsers(userList, errors); + break; + default: + // Unknown auth keys silently ignored + break; + } + } + } + + private static List ParseUsers(List list, List errors) + { + var users = new List(); + foreach (var item in list) + { + if (item is not Dictionary userDict) + { + errors.Add("Expected user entry to be a map"); + continue; + } + + string? username = null; + string? password = null; + string? account = null; + Permissions? permissions = null; + + foreach (var (key, value) in userDict) + { + switch (key.ToLowerInvariant()) + { + case "user" or "username": + username = ToString(value); + break; + case "pass" or "password": + password = ToString(value); + break; + case "account": + account = ToString(value); + break; + case "permissions" or "permission": + if (value is Dictionary permDict) + permissions = ParsePermissions(permDict, errors); + break; + } + } + + if (username is null) + { + errors.Add("User entry missing 'user' field"); + continue; + } + + users.Add(new User + { + Username = username, + Password = password ?? string.Empty, + Account = account, + Permissions = permissions, + }); + } + + return users; + } + + private static Permissions ParsePermissions(Dictionary dict, List errors) + { + SubjectPermission? publish = null; + SubjectPermission? subscribe = null; + ResponsePermission? response = null; + + foreach (var (key, value) in dict) + { + switch (key.ToLowerInvariant()) + { + case "publish" or "pub": + publish = ParseSubjectPermission(value, errors); + break; + case "subscribe" or "sub": + subscribe = ParseSubjectPermission(value, errors); + break; + case "resp" or "response": + if (value is Dictionary respDict) + response = ParseResponsePermission(respDict); + break; + } + } + + return new Permissions + { + Publish = publish, + Subscribe = subscribe, + Response = response, + }; + } + + private static SubjectPermission? ParseSubjectPermission(object? value, List errors) + { + // Can be a simple list of strings (treated as allow) or a dict with allow/deny + if (value is Dictionary dict) + { + IReadOnlyList? allow = null; + IReadOnlyList? deny = null; + + foreach (var (key, v) in dict) + { + switch (key.ToLowerInvariant()) + { + case "allow": + allow = ToStringList(v); + break; + case "deny": + deny = ToStringList(v); + break; + } + } + + return new SubjectPermission { Allow = allow, Deny = deny }; + } + + if (value is List list) + { + return new SubjectPermission { Allow = ToStringList(list) }; + } + + if (value is string s) + { + return new SubjectPermission { Allow = [s] }; + } + + return null; + } + + private static ResponsePermission ParseResponsePermission(Dictionary dict) + { + var maxMsgs = 0; + var expires = TimeSpan.Zero; + + foreach (var (key, value) in dict) + { + switch (key.ToLowerInvariant()) + { + case "max_msgs" or "max": + maxMsgs = ToInt(value); + break; + case "expires" or "ttl": + expires = ParseDuration(value); + break; + } + } + + return new ResponsePermission { MaxMsgs = maxMsgs, Expires = expires }; + } + + // ─── TLS parsing ─────────────────────────────────────────────── + + private static void ParseTls(Dictionary dict, NatsOptions opts, List errors) + { + foreach (var (key, value) in dict) + { + switch (key.ToLowerInvariant()) + { + case "cert_file": + opts.TlsCert = ToString(value); + break; + case "key_file": + opts.TlsKey = ToString(value); + break; + case "ca_file": + opts.TlsCaCert = ToString(value); + break; + case "verify": + opts.TlsVerify = ToBool(value); + break; + case "verify_and_map": + var map = ToBool(value); + opts.TlsMap = map; + if (map) + opts.TlsVerify = true; + break; + case "timeout": + opts.TlsTimeout = value switch + { + long l => TimeSpan.FromSeconds(l), + double d => TimeSpan.FromSeconds(d), + string s => ParseDuration(s), + _ => throw new FormatException($"Invalid TLS timeout type: {value?.GetType().Name}"), + }; + break; + case "connection_rate_limit": + opts.TlsRateLimit = ToLong(value); + break; + case "pinned_certs": + if (value is List pinnedList) + { + var certs = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var item in pinnedList) + { + if (item is string s) + certs.Add(s.ToLowerInvariant()); + } + + opts.TlsPinnedCerts = certs; + } + + break; + case "handshake_first" or "first" or "immediate": + opts.TlsHandshakeFirst = ToBool(value); + break; + case "handshake_first_fallback": + opts.TlsHandshakeFirstFallback = ParseDuration(value); + break; + default: + // Unknown TLS keys silently ignored + break; + } + } + } + + // ─── Tags parsing ────────────────────────────────────────────── + + private static void ParseTags(Dictionary dict, NatsOptions opts) + { + var tags = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (key, value) in dict) + { + tags[key] = ToString(value); + } + + opts.Tags = tags; + } + + // ─── Type conversion helpers ─────────────────────────────────── + + private static int ToInt(object? value) => value switch + { + long l => (int)l, + int i => i, + double d => (int)d, + string s when int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i) => i, + _ => throw new FormatException($"Cannot convert {value?.GetType().Name ?? "null"} to int"), + }; + + private static long ToLong(object? value) => value switch + { + long l => l, + int i => i, + double d => (long)d, + string s when long.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var l) => l, + _ => throw new FormatException($"Cannot convert {value?.GetType().Name ?? "null"} to long"), + }; + + private static bool ToBool(object? value) => value switch + { + bool b => b, + _ => throw new FormatException($"Cannot convert {value?.GetType().Name ?? "null"} to bool"), + }; + + private static string ToString(object? value) => value switch + { + string s => s, + long l => l.ToString(CultureInfo.InvariantCulture), + _ => throw new FormatException($"Cannot convert {value?.GetType().Name ?? "null"} to string"), + }; + + private static IReadOnlyList ToStringList(object? value) + { + if (value is List list) + { + var result = new List(list.Count); + foreach (var item in list) + { + if (item is string s) + result.Add(s); + } + + return result; + } + + if (value is string str) + return [str]; + + return []; + } +} + +/// +/// Thrown when one or more configuration validation errors are detected. +/// All errors are collected rather than failing on the first one. +/// +public sealed class ConfigProcessorException(string message, List errors) + : Exception(message) +{ + public IReadOnlyList Errors => errors; +} diff --git a/tests/NATS.Server.Tests/ConfigProcessorTests.cs b/tests/NATS.Server.Tests/ConfigProcessorTests.cs new file mode 100644 index 0000000..0ee2f39 --- /dev/null +++ b/tests/NATS.Server.Tests/ConfigProcessorTests.cs @@ -0,0 +1,504 @@ +using NATS.Server; +using NATS.Server.Configuration; + +namespace NATS.Server.Tests; + +public class ConfigProcessorTests +{ + private static string TestDataPath(string fileName) => + Path.Combine(AppContext.BaseDirectory, "TestData", fileName); + + // ─── Basic config ────────────────────────────────────────────── + + [Fact] + public void BasicConf_Port() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.Port.ShouldBe(4222); + } + + [Fact] + public void BasicConf_Host() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.Host.ShouldBe("0.0.0.0"); + } + + [Fact] + public void BasicConf_ServerName() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.ServerName.ShouldBe("test-server"); + } + + [Fact] + public void BasicConf_MaxPayload() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.MaxPayload.ShouldBe(2 * 1024 * 1024); + } + + [Fact] + public void BasicConf_MaxConnections() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.MaxConnections.ShouldBe(1000); + } + + [Fact] + public void BasicConf_Debug() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.Debug.ShouldBeTrue(); + } + + [Fact] + public void BasicConf_Trace() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.Trace.ShouldBeFalse(); + } + + [Fact] + public void BasicConf_PingInterval() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.PingInterval.ShouldBe(TimeSpan.FromSeconds(30)); + } + + [Fact] + public void BasicConf_MaxPingsOut() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.MaxPingsOut.ShouldBe(3); + } + + [Fact] + public void BasicConf_WriteDeadline() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.WriteDeadline.ShouldBe(TimeSpan.FromSeconds(5)); + } + + [Fact] + public void BasicConf_MaxSubs() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.MaxSubs.ShouldBe(100); + } + + [Fact] + public void BasicConf_MaxSubTokens() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.MaxSubTokens.ShouldBe(16); + } + + [Fact] + public void BasicConf_MaxControlLine() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.MaxControlLine.ShouldBe(2048); + } + + [Fact] + public void BasicConf_MaxPending() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.MaxPending.ShouldBe(32L * 1024 * 1024); + } + + [Fact] + public void BasicConf_LameDuckDuration() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.LameDuckDuration.ShouldBe(TimeSpan.FromSeconds(60)); + } + + [Fact] + public void BasicConf_LameDuckGracePeriod() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.LameDuckGracePeriod.ShouldBe(TimeSpan.FromSeconds(5)); + } + + [Fact] + public void BasicConf_MonitorPort() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.MonitorPort.ShouldBe(8222); + } + + [Fact] + public void BasicConf_Logtime() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.Logtime.ShouldBeTrue(); + opts.LogtimeUTC.ShouldBeFalse(); + } + + // ─── Auth config ─────────────────────────────────────────────── + + [Fact] + public void AuthConf_SimpleUser() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("auth.conf")); + opts.Username.ShouldBe("admin"); + opts.Password.ShouldBe("s3cret"); + } + + [Fact] + public void AuthConf_AuthTimeout() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("auth.conf")); + opts.AuthTimeout.ShouldBe(TimeSpan.FromSeconds(5)); + } + + [Fact] + public void AuthConf_NoAuthUser() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("auth.conf")); + opts.NoAuthUser.ShouldBe("guest"); + } + + [Fact] + public void AuthConf_UsersArray() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("auth.conf")); + opts.Users.ShouldNotBeNull(); + opts.Users.Count.ShouldBe(2); + } + + [Fact] + public void AuthConf_AliceUser() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("auth.conf")); + var alice = opts.Users!.First(u => u.Username == "alice"); + alice.Password.ShouldBe("pw1"); + alice.Permissions.ShouldNotBeNull(); + alice.Permissions!.Publish.ShouldNotBeNull(); + alice.Permissions.Publish!.Allow.ShouldNotBeNull(); + alice.Permissions.Publish.Allow!.ShouldContain("foo.>"); + alice.Permissions.Subscribe.ShouldNotBeNull(); + alice.Permissions.Subscribe!.Allow.ShouldNotBeNull(); + alice.Permissions.Subscribe.Allow!.ShouldContain(">"); + } + + [Fact] + public void AuthConf_BobUser() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("auth.conf")); + var bob = opts.Users!.First(u => u.Username == "bob"); + bob.Password.ShouldBe("pw2"); + bob.Permissions.ShouldBeNull(); + } + + // ─── TLS config ──────────────────────────────────────────────── + + [Fact] + public void TlsConf_CertFiles() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("tls.conf")); + opts.TlsCert.ShouldBe("/path/to/cert.pem"); + opts.TlsKey.ShouldBe("/path/to/key.pem"); + opts.TlsCaCert.ShouldBe("/path/to/ca.pem"); + } + + [Fact] + public void TlsConf_Verify() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("tls.conf")); + opts.TlsVerify.ShouldBeTrue(); + opts.TlsMap.ShouldBeTrue(); + } + + [Fact] + public void TlsConf_Timeout() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("tls.conf")); + opts.TlsTimeout.ShouldBe(TimeSpan.FromSeconds(3)); + } + + [Fact] + public void TlsConf_RateLimit() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("tls.conf")); + opts.TlsRateLimit.ShouldBe(100); + } + + [Fact] + public void TlsConf_PinnedCerts() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("tls.conf")); + opts.TlsPinnedCerts.ShouldNotBeNull(); + opts.TlsPinnedCerts!.Count.ShouldBe(1); + opts.TlsPinnedCerts.ShouldContain("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"); + } + + [Fact] + public void TlsConf_HandshakeFirst() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("tls.conf")); + opts.TlsHandshakeFirst.ShouldBeTrue(); + } + + [Fact] + public void TlsConf_AllowNonTls() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("tls.conf")); + opts.AllowNonTls.ShouldBeFalse(); + } + + // ─── Full config ─────────────────────────────────────────────── + + [Fact] + public void FullConf_CoreOptions() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("full.conf")); + opts.Port.ShouldBe(4222); + opts.Host.ShouldBe("0.0.0.0"); + opts.ServerName.ShouldBe("full-test"); + opts.ClientAdvertise.ShouldBe("nats://public.example.com:4222"); + } + + [Fact] + public void FullConf_Limits() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("full.conf")); + opts.MaxPayload.ShouldBe(1024 * 1024); + opts.MaxControlLine.ShouldBe(4096); + opts.MaxConnections.ShouldBe(65536); + opts.MaxPending.ShouldBe(64L * 1024 * 1024); + opts.MaxSubs.ShouldBe(0); + opts.MaxSubTokens.ShouldBe(0); + opts.MaxTracedMsgLen.ShouldBe(1024); + opts.DisableSublistCache.ShouldBeFalse(); + opts.MaxClosedClients.ShouldBe(5000); + } + + [Fact] + public void FullConf_Logging() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("full.conf")); + opts.Debug.ShouldBeFalse(); + opts.Trace.ShouldBeFalse(); + opts.TraceVerbose.ShouldBeFalse(); + opts.Logtime.ShouldBeTrue(); + opts.LogtimeUTC.ShouldBeFalse(); + opts.LogFile.ShouldBe("/var/log/nats.log"); + opts.LogSizeLimit.ShouldBe(100L * 1024 * 1024); + opts.LogMaxFiles.ShouldBe(5); + } + + [Fact] + public void FullConf_Monitoring() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("full.conf")); + opts.MonitorPort.ShouldBe(8222); + opts.MonitorBasePath.ShouldBe("/nats"); + } + + [Fact] + public void FullConf_Files() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("full.conf")); + opts.PidFile.ShouldBe("/var/run/nats.pid"); + opts.PortsFileDir.ShouldBe("/var/run"); + } + + [Fact] + public void FullConf_Lifecycle() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("full.conf")); + opts.LameDuckDuration.ShouldBe(TimeSpan.FromMinutes(2)); + opts.LameDuckGracePeriod.ShouldBe(TimeSpan.FromSeconds(10)); + } + + [Fact] + public void FullConf_Tags() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("full.conf")); + opts.Tags.ShouldNotBeNull(); + opts.Tags!["region"].ShouldBe("us-east"); + opts.Tags["env"].ShouldBe("production"); + } + + [Fact] + public void FullConf_Auth() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("full.conf")); + opts.Username.ShouldBe("admin"); + opts.Password.ShouldBe("secret"); + opts.AuthTimeout.ShouldBe(TimeSpan.FromSeconds(2)); + } + + [Fact] + public void FullConf_Tls() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("full.conf")); + opts.TlsCert.ShouldBe("/path/to/cert.pem"); + opts.TlsKey.ShouldBe("/path/to/key.pem"); + opts.TlsCaCert.ShouldBe("/path/to/ca.pem"); + opts.TlsVerify.ShouldBeTrue(); + opts.TlsTimeout.ShouldBe(TimeSpan.FromSeconds(2)); + opts.TlsHandshakeFirst.ShouldBeTrue(); + } + + // ─── Listen combined format ──────────────────────────────────── + + [Fact] + public void ListenCombined_HostAndPort() + { + var opts = ConfigProcessor.ProcessConfig("listen: \"10.0.0.1:5222\""); + opts.Host.ShouldBe("10.0.0.1"); + opts.Port.ShouldBe(5222); + } + + [Fact] + public void ListenCombined_PortOnly() + { + var opts = ConfigProcessor.ProcessConfig("listen: \":5222\""); + opts.Port.ShouldBe(5222); + } + + [Fact] + public void ListenCombined_BarePort() + { + var opts = ConfigProcessor.ProcessConfig("listen: 5222"); + opts.Port.ShouldBe(5222); + } + + // ─── HTTP combined format ────────────────────────────────────── + + [Fact] + public void HttpCombined_HostAndPort() + { + var opts = ConfigProcessor.ProcessConfig("http: \"10.0.0.1:8333\""); + opts.MonitorHost.ShouldBe("10.0.0.1"); + opts.MonitorPort.ShouldBe(8333); + } + + [Fact] + public void HttpsCombined_HostAndPort() + { + var opts = ConfigProcessor.ProcessConfig("https: \"10.0.0.1:8444\""); + opts.MonitorHost.ShouldBe("10.0.0.1"); + opts.MonitorHttpsPort.ShouldBe(8444); + } + + // ─── Duration as number ──────────────────────────────────────── + + [Fact] + public void DurationAsNumber_TreatedAsSeconds() + { + var opts = ConfigProcessor.ProcessConfig("ping_interval: 60"); + opts.PingInterval.ShouldBe(TimeSpan.FromSeconds(60)); + } + + [Fact] + public void DurationAsString_Milliseconds() + { + var opts = ConfigProcessor.ProcessConfig("write_deadline: \"500ms\""); + opts.WriteDeadline.ShouldBe(TimeSpan.FromMilliseconds(500)); + } + + [Fact] + public void DurationAsString_Hours() + { + var opts = ConfigProcessor.ProcessConfig("ping_interval: \"1h\""); + opts.PingInterval.ShouldBe(TimeSpan.FromHours(1)); + } + + // ─── Unknown keys ────────────────────────────────────────────── + + [Fact] + public void UnknownKeys_SilentlyIgnored() + { + var opts = ConfigProcessor.ProcessConfig(""" + port: 4222 + cluster { name: "my-cluster" } + jetstream { store_dir: "/tmp/js" } + unknown_key: "whatever" + """); + opts.Port.ShouldBe(4222); + } + + // ─── Server name validation ──────────────────────────────────── + + [Fact] + public void ServerNameWithSpaces_ReportsError() + { + var ex = Should.Throw(() => + ConfigProcessor.ProcessConfig("server_name: \"my server\"")); + ex.Errors.ShouldContain(e => e.Contains("server_name cannot contain spaces")); + } + + // ─── Max sub tokens validation ───────────────────────────────── + + [Fact] + public void MaxSubTokens_ExceedsLimit_ReportsError() + { + var ex = Should.Throw(() => + ConfigProcessor.ProcessConfig("max_sub_tokens: 300")); + ex.Errors.ShouldContain(e => e.Contains("max_sub_tokens cannot exceed 256")); + } + + // ─── ProcessConfig from string ───────────────────────────────── + + [Fact] + public void ProcessConfig_FromString() + { + var opts = ConfigProcessor.ProcessConfig(""" + port: 9222 + host: "127.0.0.1" + debug: true + """); + opts.Port.ShouldBe(9222); + opts.Host.ShouldBe("127.0.0.1"); + opts.Debug.ShouldBeTrue(); + } + + // ─── TraceVerbose sets Trace ──────────────────────────────────── + + [Fact] + public void TraceVerbose_AlsoSetsTrace() + { + var opts = ConfigProcessor.ProcessConfig("trace_verbose: true"); + opts.TraceVerbose.ShouldBeTrue(); + opts.Trace.ShouldBeTrue(); + } + + // ─── Error collection (not fail-fast) ────────────────────────── + + [Fact] + public void MultipleErrors_AllCollected() + { + var ex = Should.Throw(() => + ConfigProcessor.ProcessConfig(""" + server_name: "bad name" + max_sub_tokens: 999 + """)); + ex.Errors.Count.ShouldBe(2); + ex.Errors.ShouldContain(e => e.Contains("server_name")); + ex.Errors.ShouldContain(e => e.Contains("max_sub_tokens")); + } + + // ─── ConfigFile path tracking ────────────────────────────────── + + [Fact] + public void ProcessConfigFile_SetsConfigFilePath() + { + var path = TestDataPath("basic.conf"); + var opts = ConfigProcessor.ProcessConfigFile(path); + opts.ConfigFile.ShouldBe(path); + } + + // ─── HasTls derived property ─────────────────────────────────── + + [Fact] + public void HasTls_TrueWhenCertAndKeySet() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("tls.conf")); + opts.HasTls.ShouldBeTrue(); + } +} diff --git a/tests/NATS.Server.Tests/NATS.Server.Tests.csproj b/tests/NATS.Server.Tests/NATS.Server.Tests.csproj index 67f3fe4..503f4df 100644 --- a/tests/NATS.Server.Tests/NATS.Server.Tests.csproj +++ b/tests/NATS.Server.Tests/NATS.Server.Tests.csproj @@ -21,6 +21,10 @@ + + + + diff --git a/tests/NATS.Server.Tests/TestData/auth.conf b/tests/NATS.Server.Tests/TestData/auth.conf new file mode 100644 index 0000000..bb16a3e --- /dev/null +++ b/tests/NATS.Server.Tests/TestData/auth.conf @@ -0,0 +1,11 @@ +authorization { + user: admin + password: "s3cret" + timeout: 5 + + users = [ + { user: alice, password: "pw1", permissions: { publish: { allow: ["foo.>"] }, subscribe: { allow: [">"] } } } + { user: bob, password: "pw2" } + ] +} +no_auth_user: "guest" diff --git a/tests/NATS.Server.Tests/TestData/basic.conf b/tests/NATS.Server.Tests/TestData/basic.conf new file mode 100644 index 0000000..27ad7ef --- /dev/null +++ b/tests/NATS.Server.Tests/TestData/basic.conf @@ -0,0 +1,19 @@ +port: 4222 +host: "0.0.0.0" +server_name: "test-server" +max_payload: 2mb +max_connections: 1000 +debug: true +trace: false +logtime: true +logtime_utc: false +ping_interval: "30s" +ping_max: 3 +write_deadline: "5s" +max_subs: 100 +max_sub_tokens: 16 +max_control_line: 2048 +max_pending: 32mb +lame_duck_duration: "60s" +lame_duck_grace_period: "5s" +http_port: 8222 diff --git a/tests/NATS.Server.Tests/TestData/full.conf b/tests/NATS.Server.Tests/TestData/full.conf new file mode 100644 index 0000000..bb4d61e --- /dev/null +++ b/tests/NATS.Server.Tests/TestData/full.conf @@ -0,0 +1,57 @@ +# Full configuration with all supported options +port: 4222 +host: "0.0.0.0" +server_name: "full-test" +client_advertise: "nats://public.example.com:4222" + +max_payload: 1mb +max_control_line: 4096 +max_connections: 65536 +max_pending: 64mb +write_deadline: "10s" +max_subs: 0 +max_sub_tokens: 0 +max_traced_msg_len: 1024 +disable_sublist_cache: false +max_closed_clients: 5000 + +ping_interval: "2m" +ping_max: 2 + +debug: false +trace: false +trace_verbose: false +logtime: true +logtime_utc: false +logfile: "/var/log/nats.log" +log_size_limit: 100mb +log_max_num: 5 + +http_port: 8222 +http_base_path: "/nats" + +pidfile: "/var/run/nats.pid" +ports_file_dir: "/var/run" + +lame_duck_duration: "2m" +lame_duck_grace_period: "10s" + +server_tags { + region: "us-east" + env: "production" +} + +authorization { + user: admin + password: "secret" + timeout: 2 +} + +tls { + cert_file: "/path/to/cert.pem" + key_file: "/path/to/key.pem" + ca_file: "/path/to/ca.pem" + verify: true + timeout: 2 + handshake_first: true +} diff --git a/tests/NATS.Server.Tests/TestData/tls.conf b/tests/NATS.Server.Tests/TestData/tls.conf new file mode 100644 index 0000000..d8a99de --- /dev/null +++ b/tests/NATS.Server.Tests/TestData/tls.conf @@ -0,0 +1,12 @@ +tls { + cert_file: "/path/to/cert.pem" + key_file: "/path/to/key.pem" + ca_file: "/path/to/ca.pem" + verify: true + verify_and_map: true + timeout: 3 + connection_rate_limit: 100 + pinned_certs: ["abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"] + handshake_first: true +} +allow_non_tls: false