- Rename tests/NATS.Server.Tests -> tests/NATS.Server.Core.Tests - Update solution file, InternalsVisibleTo, and csproj references - Remove JETSTREAM_INTEGRATION_MATRIX and NATS.NKeys from csproj (moved to JetStream.Tests and Auth.Tests) - Update all namespaces from NATS.Server.Tests.* to NATS.Server.Core.Tests.* - Replace private GetFreePort/ReadUntilAsync helpers with TestUtilities calls - Fix stale namespace in Transport.Tests/NetworkingGoParityTests.cs
2554 lines
91 KiB
C#
2554 lines
91 KiB
C#
// Port of Go server/opts_test.go — config parsing and options parity tests.
|
|
// Reference: golang/nats-server/server/opts_test.go
|
|
|
|
using System.Net;
|
|
using System.Net.Sockets;
|
|
using NATS.Server.Auth;
|
|
using NATS.Server.Configuration;
|
|
|
|
namespace NATS.Server.Core.Tests.Configuration;
|
|
|
|
/// <summary>
|
|
/// Parity tests ported from Go server/opts_test.go that exercise config parsing,
|
|
/// option defaults, variable substitution, and authorization block parsing.
|
|
/// </summary>
|
|
public class OptsGoParityTests
|
|
{
|
|
// ─── Helpers ────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Creates a temporary config file with the given content and returns its path.
|
|
/// The file is deleted after the test via the returned IDisposable registered
|
|
/// with a finalizer helper.
|
|
/// </summary>
|
|
private static string CreateTempConf(string content)
|
|
{
|
|
var path = Path.GetTempFileName();
|
|
File.WriteAllText(path, content);
|
|
return path;
|
|
}
|
|
|
|
// ─── TestOptions_RandomPort ──────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestOptions_RandomPort server/opts_test.go:87
|
|
///
|
|
/// In Go, port=-1 (RANDOM_PORT) is resolved to 0 (ephemeral) by setBaselineOptions.
|
|
/// In .NET, port=-1 means "use the OS ephemeral port". We verify that parsing
|
|
/// "listen: -1" or setting Port=-1 does NOT produce a normal port, and that
|
|
/// port=0 is the canonical ephemeral indicator in the .NET implementation.
|
|
/// </summary>
|
|
[Fact]
|
|
public void RandomPort_NegativeOne_IsEphemeral()
|
|
{
|
|
// Go: RANDOM_PORT = -1; setBaselineOptions resolves it to 0.
|
|
// In .NET we can parse port: -1 from config to get port=-1, which the
|
|
// server treats as ephemeral (it will bind to port 0 on the OS).
|
|
// Verify the .NET parser accepts it without error.
|
|
var opts = ConfigProcessor.ProcessConfig("port: -1");
|
|
opts.Port.ShouldBe(-1);
|
|
}
|
|
|
|
[Fact]
|
|
public void RandomPort_Zero_IsEphemeral()
|
|
{
|
|
// Port 0 is the canonical OS ephemeral port indicator.
|
|
var opts = ConfigProcessor.ProcessConfig("port: 0");
|
|
opts.Port.ShouldBe(0);
|
|
}
|
|
|
|
// ─── TestListenPortOnlyConfig ─────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestListenPortOnlyConfig server/opts_test.go:507
|
|
///
|
|
/// Verifies that a config containing only "listen: 8922" (bare port number)
|
|
/// is parsed correctly — host stays as the default, port is set to 8922.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ListenPortOnly_ParsesBarePort()
|
|
{
|
|
// Go test loads ./configs/listen_port.conf which contains "listen: 8922"
|
|
var conf = CreateTempConf("listen: 8922\n");
|
|
try
|
|
{
|
|
var opts = ConfigProcessor.ProcessConfigFile(conf);
|
|
opts.Port.ShouldBe(8922);
|
|
// Host should remain at the default (0.0.0.0)
|
|
opts.Host.ShouldBe("0.0.0.0");
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(conf);
|
|
}
|
|
}
|
|
|
|
// ─── TestListenPortWithColonConfig ────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestListenPortWithColonConfig server/opts_test.go:527
|
|
///
|
|
/// Verifies that "listen: :8922" (colon-prefixed port) is parsed correctly —
|
|
/// the host part is empty so host stays at default, port is set to 8922.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ListenPortWithColon_ParsesPortOnly()
|
|
{
|
|
// Go test loads ./configs/listen_port_with_colon.conf which contains "listen: :8922"
|
|
var conf = CreateTempConf("listen: \":8922\"\n");
|
|
try
|
|
{
|
|
var opts = ConfigProcessor.ProcessConfigFile(conf);
|
|
opts.Port.ShouldBe(8922);
|
|
// Host should remain at the default (0.0.0.0), not empty string
|
|
opts.Host.ShouldBe("0.0.0.0");
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(conf);
|
|
}
|
|
}
|
|
|
|
// ─── TestMultipleUsersConfig ──────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestMultipleUsersConfig server/opts_test.go:565
|
|
///
|
|
/// Verifies that a config with multiple users in an authorization block
|
|
/// is parsed without error and produces the correct user list.
|
|
/// </summary>
|
|
[Fact]
|
|
public void MultipleUsers_ParsesWithoutError()
|
|
{
|
|
// Go test loads ./configs/multiple_users.conf which has 2 users
|
|
var conf = CreateTempConf("""
|
|
listen: "127.0.0.1:4443"
|
|
|
|
authorization {
|
|
users = [
|
|
{user: alice, password: foo}
|
|
{user: bob, password: bar}
|
|
]
|
|
timeout: 0.5
|
|
}
|
|
""");
|
|
try
|
|
{
|
|
var opts = ConfigProcessor.ProcessConfigFile(conf);
|
|
opts.Users.ShouldNotBeNull();
|
|
opts.Users!.Count.ShouldBe(2);
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(conf);
|
|
}
|
|
}
|
|
|
|
// ─── TestAuthorizationConfig ──────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestAuthorizationConfig server/opts_test.go:575
|
|
///
|
|
/// Verifies authorization block parsing: users array, per-user permissions
|
|
/// (publish/subscribe), and allow_responses (ResponsePermission).
|
|
/// The Go test uses ./configs/authorization.conf which has 5 users with
|
|
/// varying permission configurations including variable references.
|
|
/// We inline an equivalent config here.
|
|
/// </summary>
|
|
[Fact]
|
|
public void AuthorizationConfig_UsersAndPermissions()
|
|
{
|
|
var conf = CreateTempConf("""
|
|
authorization {
|
|
users = [
|
|
{user: alice, password: foo, permissions: { publish: { allow: ["*"] }, subscribe: { allow: [">"] } } }
|
|
{user: bob, password: bar, permissions: { publish: { allow: ["req.foo", "req.bar"] }, subscribe: { allow: ["_INBOX.>"] } } }
|
|
{user: susan, password: baz, permissions: { subscribe: { allow: ["PUBLIC.>"] } } }
|
|
{user: svca, password: pc, permissions: { subscribe: { allow: ["my.service.req"] }, publish: { allow: [] }, resp: { max: 1, expires: "0s" } } }
|
|
{user: svcb, password: sam, permissions: { subscribe: { allow: ["my.service.req"] }, publish: { allow: [] }, resp: { max: 10, expires: "1m" } } }
|
|
]
|
|
}
|
|
""");
|
|
try
|
|
{
|
|
var opts = ConfigProcessor.ProcessConfigFile(conf);
|
|
|
|
opts.Users.ShouldNotBeNull();
|
|
opts.Users!.Count.ShouldBe(5);
|
|
|
|
// Build a map for easy lookup
|
|
var userMap = opts.Users.ToDictionary(u => u.Username);
|
|
|
|
// Alice: publish="*", subscribe=">"
|
|
var alice = userMap["alice"];
|
|
alice.Permissions.ShouldNotBeNull();
|
|
alice.Permissions!.Publish.ShouldNotBeNull();
|
|
alice.Permissions.Publish!.Allow.ShouldNotBeNull();
|
|
alice.Permissions.Publish.Allow!.ShouldContain("*");
|
|
alice.Permissions.Subscribe.ShouldNotBeNull();
|
|
alice.Permissions.Subscribe!.Allow.ShouldNotBeNull();
|
|
alice.Permissions.Subscribe.Allow!.ShouldContain(">");
|
|
|
|
// Bob: publish=["req.foo","req.bar"], subscribe=["_INBOX.>"]
|
|
var bob = userMap["bob"];
|
|
bob.Permissions.ShouldNotBeNull();
|
|
bob.Permissions!.Publish.ShouldNotBeNull();
|
|
bob.Permissions.Publish!.Allow!.ShouldContain("req.foo");
|
|
bob.Permissions.Publish.Allow!.ShouldContain("req.bar");
|
|
bob.Permissions.Subscribe!.Allow!.ShouldContain("_INBOX.>");
|
|
|
|
// Susan: subscribe="PUBLIC.>", no publish perms
|
|
var susan = userMap["susan"];
|
|
susan.Permissions.ShouldNotBeNull();
|
|
susan.Permissions!.Publish.ShouldBeNull();
|
|
susan.Permissions.Subscribe.ShouldNotBeNull();
|
|
susan.Permissions.Subscribe!.Allow!.ShouldContain("PUBLIC.>");
|
|
|
|
// Service B (svcb): response permissions max=10, expires=1m
|
|
var svcb = userMap["svcb"];
|
|
svcb.Permissions.ShouldNotBeNull();
|
|
svcb.Permissions!.Response.ShouldNotBeNull();
|
|
svcb.Permissions.Response!.MaxMsgs.ShouldBe(10);
|
|
svcb.Permissions.Response.Expires.ShouldBe(TimeSpan.FromMinutes(1));
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(conf);
|
|
}
|
|
}
|
|
|
|
// ─── TestAuthorizationConfig — simple token block ─────────────────────────
|
|
|
|
[Fact]
|
|
public void AuthorizationConfig_TokenAndTimeout()
|
|
{
|
|
// Go: TestAuthorizationConfig also verifies the top-level authorization block
|
|
// with user/password/timeout fields.
|
|
var opts = ConfigProcessor.ProcessConfig("""
|
|
authorization {
|
|
user: admin
|
|
password: "s3cr3t"
|
|
timeout: 3
|
|
}
|
|
""");
|
|
opts.Username.ShouldBe("admin");
|
|
opts.Password.ShouldBe("s3cr3t");
|
|
opts.AuthTimeout.ShouldBe(TimeSpan.FromSeconds(3));
|
|
}
|
|
|
|
// ─── TestOptionsClone ─────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestOptionsClone server/opts_test.go:1221
|
|
///
|
|
/// Verifies that a populated NatsOptions is correctly copied by a clone
|
|
/// operation and that mutating the clone does not affect the original.
|
|
/// In .NET, NatsOptions is mutable so "clone" means making a shallow-enough
|
|
/// copy of the value properties.
|
|
/// </summary>
|
|
[Fact]
|
|
public void OptionsClone_ProducesIndependentCopy()
|
|
{
|
|
var opts = new NatsOptions
|
|
{
|
|
Host = "127.0.0.1",
|
|
Port = 2222,
|
|
Username = "derek",
|
|
Password = "porkchop",
|
|
Debug = true,
|
|
Trace = true,
|
|
PidFile = "/tmp/nats-server/nats-server.pid",
|
|
ProfPort = 6789,
|
|
Syslog = true,
|
|
RemoteSyslog = "udp://foo.com:33",
|
|
MaxControlLine = 2048,
|
|
MaxPayload = 65536,
|
|
MaxConnections = 100,
|
|
PingInterval = TimeSpan.FromSeconds(60),
|
|
MaxPingsOut = 3,
|
|
};
|
|
|
|
// Simulate a shallow clone by constructing a copy
|
|
var clone = new NatsOptions
|
|
{
|
|
Host = opts.Host,
|
|
Port = opts.Port,
|
|
Username = opts.Username,
|
|
Password = opts.Password,
|
|
Debug = opts.Debug,
|
|
Trace = opts.Trace,
|
|
PidFile = opts.PidFile,
|
|
ProfPort = opts.ProfPort,
|
|
Syslog = opts.Syslog,
|
|
RemoteSyslog = opts.RemoteSyslog,
|
|
MaxControlLine = opts.MaxControlLine,
|
|
MaxPayload = opts.MaxPayload,
|
|
MaxConnections = opts.MaxConnections,
|
|
PingInterval = opts.PingInterval,
|
|
MaxPingsOut = opts.MaxPingsOut,
|
|
};
|
|
|
|
// Verify all copied fields
|
|
clone.Host.ShouldBe(opts.Host);
|
|
clone.Port.ShouldBe(opts.Port);
|
|
clone.Username.ShouldBe(opts.Username);
|
|
clone.Password.ShouldBe(opts.Password);
|
|
clone.Debug.ShouldBe(opts.Debug);
|
|
clone.Trace.ShouldBe(opts.Trace);
|
|
clone.PidFile.ShouldBe(opts.PidFile);
|
|
clone.ProfPort.ShouldBe(opts.ProfPort);
|
|
clone.Syslog.ShouldBe(opts.Syslog);
|
|
clone.RemoteSyslog.ShouldBe(opts.RemoteSyslog);
|
|
clone.MaxControlLine.ShouldBe(opts.MaxControlLine);
|
|
clone.MaxPayload.ShouldBe(opts.MaxPayload);
|
|
clone.MaxConnections.ShouldBe(opts.MaxConnections);
|
|
clone.PingInterval.ShouldBe(opts.PingInterval);
|
|
clone.MaxPingsOut.ShouldBe(opts.MaxPingsOut);
|
|
|
|
// Mutating the clone should not affect the original
|
|
clone.Password = "new_password";
|
|
opts.Password.ShouldBe("porkchop");
|
|
|
|
clone.Port = 9999;
|
|
opts.Port.ShouldBe(2222);
|
|
}
|
|
|
|
// ─── TestOptionsCloneNilLists ──────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestOptionsCloneNilLists server/opts_test.go:1281
|
|
///
|
|
/// Verifies that cloning an empty Options struct produces nil/empty collections,
|
|
/// not empty-but-non-nil lists. In .NET, an unset NatsOptions.Users is null.
|
|
/// </summary>
|
|
[Fact]
|
|
public void OptionsCloneNilLists_UsersIsNullByDefault()
|
|
{
|
|
// Go: opts := &Options{}; clone := opts.Clone(); clone.Users should be nil.
|
|
var opts = new NatsOptions();
|
|
opts.Users.ShouldBeNull();
|
|
}
|
|
|
|
// ─── TestProcessConfigString ──────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestProcessConfigString server/opts_test.go:3407
|
|
///
|
|
/// Verifies that ProcessConfig (from string) can parse basic option values
|
|
/// without requiring a file on disk.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ProcessConfigString_ParsesBasicOptions()
|
|
{
|
|
// Go uses opts.ProcessConfigString(config); .NET equivalent is ConfigProcessor.ProcessConfig.
|
|
var opts = ConfigProcessor.ProcessConfig("""
|
|
port: 9222
|
|
host: "127.0.0.1"
|
|
debug: true
|
|
max_connections: 500
|
|
""");
|
|
|
|
opts.Port.ShouldBe(9222);
|
|
opts.Host.ShouldBe("127.0.0.1");
|
|
opts.Debug.ShouldBeTrue();
|
|
opts.MaxConnections.ShouldBe(500);
|
|
}
|
|
|
|
[Fact]
|
|
public void ProcessConfigString_MultipleOptions()
|
|
{
|
|
var opts = ConfigProcessor.ProcessConfig("""
|
|
port: 4333
|
|
server_name: "myserver"
|
|
max_payload: 65536
|
|
ping_interval: "30s"
|
|
""");
|
|
|
|
opts.Port.ShouldBe(4333);
|
|
opts.ServerName.ShouldBe("myserver");
|
|
opts.MaxPayload.ShouldBe(65536);
|
|
opts.PingInterval.ShouldBe(TimeSpan.FromSeconds(30));
|
|
}
|
|
|
|
// ─── TestDefaultSentinel ──────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestDefaultSentinel server/opts_test.go:3489
|
|
///
|
|
/// Verifies that .NET NatsOptions defaults match expected sentinel values.
|
|
/// In Go, setBaselineOptions populates defaults. In .NET, defaults are defined
|
|
/// in NatsOptions property initializers.
|
|
/// </summary>
|
|
[Fact]
|
|
public void DefaultOptions_PortIs4222()
|
|
{
|
|
var opts = new NatsOptions();
|
|
opts.Port.ShouldBe(4222);
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultOptions_HostIs0000()
|
|
{
|
|
var opts = new NatsOptions();
|
|
opts.Host.ShouldBe("0.0.0.0");
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultOptions_MaxPayloadIs1MB()
|
|
{
|
|
var opts = new NatsOptions();
|
|
opts.MaxPayload.ShouldBe(1024 * 1024);
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultOptions_MaxControlLineIs4096()
|
|
{
|
|
var opts = new NatsOptions();
|
|
opts.MaxControlLine.ShouldBe(4096);
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultOptions_MaxConnectionsIs65536()
|
|
{
|
|
var opts = new NatsOptions();
|
|
opts.MaxConnections.ShouldBe(65536);
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultOptions_PingIntervalIs2Minutes()
|
|
{
|
|
var opts = new NatsOptions();
|
|
opts.PingInterval.ShouldBe(TimeSpan.FromMinutes(2));
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultOptions_MaxPingsOutIs2()
|
|
{
|
|
var opts = new NatsOptions();
|
|
opts.MaxPingsOut.ShouldBe(2);
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultOptions_WriteDeadlineIs10Seconds()
|
|
{
|
|
var opts = new NatsOptions();
|
|
opts.WriteDeadline.ShouldBe(TimeSpan.FromSeconds(10));
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultOptions_AuthTimeoutIs2Seconds()
|
|
{
|
|
var opts = new NatsOptions();
|
|
opts.AuthTimeout.ShouldBe(TimeSpan.FromSeconds(2));
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultOptions_LameDuckDurationIs2Minutes()
|
|
{
|
|
var opts = new NatsOptions();
|
|
opts.LameDuckDuration.ShouldBe(TimeSpan.FromMinutes(2));
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultOptions_MaxClosedClientsIs10000()
|
|
{
|
|
var opts = new NatsOptions();
|
|
opts.MaxClosedClients.ShouldBe(10_000);
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultOptions_MaxSubsIsZero_Unlimited()
|
|
{
|
|
var opts = new NatsOptions();
|
|
opts.MaxSubs.ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultOptions_DebugAndTraceAreFalse()
|
|
{
|
|
var opts = new NatsOptions();
|
|
opts.Debug.ShouldBeFalse();
|
|
opts.Trace.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultOptions_MaxPendingIs64MB()
|
|
{
|
|
var opts = new NatsOptions();
|
|
opts.MaxPending.ShouldBe(64L * 1024 * 1024);
|
|
}
|
|
|
|
// ─── TestWriteDeadlineConfigParsing ───────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestParseWriteDeadline server/opts_test.go:1187
|
|
///
|
|
/// Verifies write_deadline parsing from config strings:
|
|
/// - Invalid unit ("1x") should throw
|
|
/// - Valid string "1s" should produce 1 second
|
|
/// - Bare integer 2 should produce 2 seconds (treated as seconds)
|
|
/// </summary>
|
|
[Fact]
|
|
public void WriteDeadline_InvalidUnit_ThrowsException()
|
|
{
|
|
// Go: expects error containing "parsing"
|
|
var conf = CreateTempConf("write_deadline: \"1x\"");
|
|
try
|
|
{
|
|
Should.Throw<Exception>(() => ConfigProcessor.ProcessConfigFile(conf));
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(conf);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void WriteDeadline_ValidStringSeconds_Parsed()
|
|
{
|
|
var conf = CreateTempConf("write_deadline: \"1s\"");
|
|
try
|
|
{
|
|
var opts = ConfigProcessor.ProcessConfigFile(conf);
|
|
opts.WriteDeadline.ShouldBe(TimeSpan.FromSeconds(1));
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(conf);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void WriteDeadline_BareInteger_TreatedAsSeconds()
|
|
{
|
|
// Go: write_deadline: 2 (integer) is treated as 2 seconds
|
|
var conf = CreateTempConf("write_deadline: 2");
|
|
try
|
|
{
|
|
var opts = ConfigProcessor.ProcessConfigFile(conf);
|
|
opts.WriteDeadline.ShouldBe(TimeSpan.FromSeconds(2));
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(conf);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void WriteDeadline_StringMilliseconds_Parsed()
|
|
{
|
|
var opts = ConfigProcessor.ProcessConfig("write_deadline: \"500ms\"");
|
|
opts.WriteDeadline.ShouldBe(TimeSpan.FromMilliseconds(500));
|
|
}
|
|
|
|
[Fact]
|
|
public void WriteDeadline_StringMinutes_Parsed()
|
|
{
|
|
var opts = ConfigProcessor.ProcessConfig("write_deadline: \"2m\"");
|
|
opts.WriteDeadline.ShouldBe(TimeSpan.FromMinutes(2));
|
|
}
|
|
|
|
// ─── TestWriteTimeoutConfigParsing alias ──────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestWriteTimeoutConfigParsing server/opts_test.go:4059
|
|
///
|
|
/// In Go, write_timeout is a policy enum (default/retry/close) on cluster/gateway/leafnode.
|
|
/// In .NET the field is write_deadline which is a TimeSpan. We verify the .NET
|
|
/// duration parsing is consistent with what the Go reference parses for the client-facing
|
|
/// write deadline field (not the per-subsystem policy).
|
|
/// </summary>
|
|
[Fact]
|
|
public void WriteDeadline_AllDurationFormats_Parsed()
|
|
{
|
|
// Verify all supported duration formats
|
|
ConfigProcessor.ProcessConfig("write_deadline: \"30s\"").WriteDeadline
|
|
.ShouldBe(TimeSpan.FromSeconds(30));
|
|
|
|
ConfigProcessor.ProcessConfig("write_deadline: \"1h\"").WriteDeadline
|
|
.ShouldBe(TimeSpan.FromHours(1));
|
|
|
|
ConfigProcessor.ProcessConfig("write_deadline: 60").WriteDeadline
|
|
.ShouldBe(TimeSpan.FromSeconds(60));
|
|
}
|
|
|
|
// ─── TestExpandPath ────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestExpandPath server/opts_test.go:2808
|
|
///
|
|
/// Verifies that file paths in config values that contain "~" are expanded
|
|
/// to the home directory. The .NET port does not yet have a dedicated
|
|
/// expandPath helper, but we verify that file paths are accepted as-is and
|
|
/// that the PidFile / LogFile fields store the raw value parsed from config.
|
|
/// </summary>
|
|
[Fact]
|
|
public void PathConfig_AbsolutePathStoredVerbatim()
|
|
{
|
|
// Go: {path: "/foo/bar", wantPath: "/foo/bar"}
|
|
var opts = ConfigProcessor.ProcessConfig("pid_file: \"/foo/bar/nats.pid\"");
|
|
opts.PidFile.ShouldBe("/foo/bar/nats.pid");
|
|
}
|
|
|
|
[Fact]
|
|
public void PathConfig_RelativePathStoredVerbatim()
|
|
{
|
|
// Go: {path: "foo/bar", wantPath: "foo/bar"}
|
|
var opts = ConfigProcessor.ProcessConfig("log_file: \"foo/bar/nats.log\"");
|
|
opts.LogFile.ShouldBe("foo/bar/nats.log");
|
|
}
|
|
|
|
[Fact]
|
|
public void PathConfig_HomeDirectory_TildeIsStoredVerbatim()
|
|
{
|
|
// In Go, expandPath("~/fizz") expands using $HOME.
|
|
// In the .NET config parser the raw value is stored; expansion
|
|
// happens at server startup. Verify the parser does not choke on it.
|
|
var opts = ConfigProcessor.ProcessConfig("pid_file: \"~/nats/nats.pid\"");
|
|
opts.PidFile.ShouldBe("~/nats/nats.pid");
|
|
}
|
|
|
|
// ─── TestVarReferencesVar ─────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestVarReferencesVar server/opts_test.go:4186
|
|
///
|
|
/// Verifies that a config variable can reference another variable defined
|
|
/// earlier in the same file and the final value is correctly resolved.
|
|
/// </summary>
|
|
[Fact]
|
|
public void VarReferencesVar_ChainedResolution()
|
|
{
|
|
// Go test: A: 7890, B: $A, C: $B, port: $C → port = 7890
|
|
var conf = CreateTempConf("""
|
|
A: 7890
|
|
B: $A
|
|
C: $B
|
|
port: $C
|
|
""");
|
|
try
|
|
{
|
|
var opts = ConfigProcessor.ProcessConfigFile(conf);
|
|
opts.Port.ShouldBe(7890);
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(conf);
|
|
}
|
|
}
|
|
|
|
// ─── TestVarReferencesEnvVar ──────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestVarReferencesEnvVar server/opts_test.go:4203
|
|
///
|
|
/// Verifies that a config variable can reference an environment variable
|
|
/// and the chain A: $ENV_VAR, B: $A, port: $B resolves correctly.
|
|
/// </summary>
|
|
[Fact]
|
|
public void VarReferencesEnvVar_ChainedResolution()
|
|
{
|
|
// Go test: A: $_TEST_ENV_NATS_PORT_, B: $A, C: $B, port: $C → port = 7890
|
|
var envVar = "_DOTNET_TEST_NATS_PORT_" + Guid.NewGuid().ToString("N")[..8].ToUpperInvariant();
|
|
Environment.SetEnvironmentVariable(envVar, "7890");
|
|
try
|
|
{
|
|
var conf = CreateTempConf($"""
|
|
A: ${envVar}
|
|
B: $A
|
|
C: $B
|
|
port: $C
|
|
""");
|
|
try
|
|
{
|
|
var opts = ConfigProcessor.ProcessConfigFile(conf);
|
|
opts.Port.ShouldBe(7890);
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(conf);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
Environment.SetEnvironmentVariable(envVar, null);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void VarReferencesEnvVar_DirectEnvVarInPort()
|
|
{
|
|
// Direct: port: $ENV_VAR (no intermediate variable)
|
|
var envVar = "_DOTNET_TEST_PORT_" + Guid.NewGuid().ToString("N")[..8].ToUpperInvariant();
|
|
Environment.SetEnvironmentVariable(envVar, "8765");
|
|
try
|
|
{
|
|
var conf = CreateTempConf($"port: ${envVar}\n");
|
|
try
|
|
{
|
|
var opts = ConfigProcessor.ProcessConfigFile(conf);
|
|
opts.Port.ShouldBe(8765);
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(conf);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
Environment.SetEnvironmentVariable(envVar, null);
|
|
}
|
|
}
|
|
|
|
// ─── TestHandleUnknownTopLevelConfigurationField ───────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestHandleUnknownTopLevelConfigurationField server/opts_test.go:2632
|
|
///
|
|
/// Verifies that unknown top-level config fields are silently ignored
|
|
/// (the .NET ConfigProcessor uses a default: break in its switch statement,
|
|
/// so unknown keys are no-ops). The Go test verifies that a "streaming" block
|
|
/// which is unknown does not cause a crash.
|
|
/// </summary>
|
|
[Fact]
|
|
public void UnknownTopLevelField_SilentlyIgnored()
|
|
{
|
|
// Go test: port: 1234, streaming { id: "me" } → should not error,
|
|
// NoErrOnUnknownFields(true) mode. In .NET, unknown fields are always ignored.
|
|
var opts = ConfigProcessor.ProcessConfig("""
|
|
port: 1234
|
|
streaming {
|
|
id: "me"
|
|
}
|
|
""");
|
|
opts.Port.ShouldBe(1234);
|
|
}
|
|
|
|
[Fact]
|
|
public void UnknownTopLevelField_KnownFieldsStillParsed()
|
|
{
|
|
var opts = ConfigProcessor.ProcessConfig("""
|
|
port: 5555
|
|
totally_unknown_field: "some_value"
|
|
server_name: "my-server"
|
|
""");
|
|
opts.Port.ShouldBe(5555);
|
|
opts.ServerName.ShouldBe("my-server");
|
|
}
|
|
|
|
// ─── Additional coverage: authorization block defaults ────────────────────
|
|
|
|
[Fact]
|
|
public void Authorization_SimpleUserPassword_WithTimeout()
|
|
{
|
|
var opts = ConfigProcessor.ProcessConfig("""
|
|
authorization {
|
|
user: "testuser"
|
|
password: "testpass"
|
|
timeout: 5
|
|
}
|
|
""");
|
|
opts.Username.ShouldBe("testuser");
|
|
opts.Password.ShouldBe("testpass");
|
|
opts.AuthTimeout.ShouldBe(TimeSpan.FromSeconds(5));
|
|
}
|
|
|
|
[Fact]
|
|
public void Authorization_TokenField()
|
|
{
|
|
var opts = ConfigProcessor.ProcessConfig("""
|
|
authorization {
|
|
token: "my_secret_token"
|
|
}
|
|
""");
|
|
opts.Authorization.ShouldBe("my_secret_token");
|
|
}
|
|
|
|
[Fact]
|
|
public void Authorization_TimeoutAsFloat_ParsedAsSeconds()
|
|
{
|
|
// Go's authorization timeout can be a float (e.g., 0.5 seconds)
|
|
var opts = ConfigProcessor.ProcessConfig("""
|
|
authorization {
|
|
user: alice
|
|
password: foo
|
|
timeout: 0.5
|
|
}
|
|
""");
|
|
opts.AuthTimeout.ShouldBe(TimeSpan.FromSeconds(0.5));
|
|
}
|
|
|
|
// ─── Listen combined format (colon-port) ─────────────────────────────────
|
|
|
|
[Fact]
|
|
public void Listen_BarePortNumber_SetsPort()
|
|
{
|
|
var opts = ConfigProcessor.ProcessConfig("listen: 5222");
|
|
opts.Port.ShouldBe(5222);
|
|
}
|
|
|
|
[Fact]
|
|
public void Listen_ColonPort_SetsPort()
|
|
{
|
|
var opts = ConfigProcessor.ProcessConfig("listen: \":5222\"");
|
|
opts.Port.ShouldBe(5222);
|
|
}
|
|
|
|
[Fact]
|
|
public void Listen_HostAndPort_SetsBoth()
|
|
{
|
|
var opts = ConfigProcessor.ProcessConfig("listen: \"127.0.0.1:5222\"");
|
|
opts.Host.ShouldBe("127.0.0.1");
|
|
opts.Port.ShouldBe(5222);
|
|
}
|
|
|
|
// ─── Empty config ──────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestEmptyConfig server/opts_test.go:1302
|
|
///
|
|
/// Verifies that an empty config string is parsed without error
|
|
/// and produces default option values.
|
|
/// </summary>
|
|
[Fact]
|
|
public void EmptyConfig_ProducesDefaults()
|
|
{
|
|
// Go: ProcessConfigFile("") succeeds, opts.ConfigFile == ""
|
|
var opts = ConfigProcessor.ProcessConfig("");
|
|
opts.Port.ShouldBe(4222);
|
|
opts.Host.ShouldBe("0.0.0.0");
|
|
}
|
|
|
|
// ─── MaxClosedClients ──────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestMaxClosedClients server/opts_test.go:1340
|
|
///
|
|
/// Verifies that max_closed_clients is parsed correctly.
|
|
/// </summary>
|
|
[Fact]
|
|
public void MaxClosedClients_Parsed()
|
|
{
|
|
// Go: max_closed_clients: 5 → opts.MaxClosedClients == 5
|
|
var opts = ConfigProcessor.ProcessConfig("max_closed_clients: 5");
|
|
opts.MaxClosedClients.ShouldBe(5);
|
|
}
|
|
|
|
// ─── PingInterval ─────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestPingIntervalNew server/opts_test.go:1369
|
|
///
|
|
/// Verifies that a quoted duration string for ping_interval parses correctly.
|
|
/// </summary>
|
|
[Fact]
|
|
public void PingInterval_QuotedDurationString_Parsed()
|
|
{
|
|
// Go: ping_interval: "5m" → opts.PingInterval = 5 minutes
|
|
var opts = ConfigProcessor.ProcessConfig("ping_interval: \"5m\"");
|
|
opts.PingInterval.ShouldBe(TimeSpan.FromMinutes(5));
|
|
}
|
|
|
|
[Fact]
|
|
public void PingInterval_BareIntegerSeconds_Parsed()
|
|
{
|
|
// Go: TestPingIntervalOld — ping_interval: 5 (bare integer treated as seconds)
|
|
var opts = ConfigProcessor.ProcessConfig("ping_interval: 5");
|
|
opts.PingInterval.ShouldBe(TimeSpan.FromSeconds(5));
|
|
}
|
|
|
|
// ─── TestRouteFlagOverride ─────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestRouteFlagOverride server/opts_test.go:328
|
|
///
|
|
/// Verifies that cluster route parsing works. In Go, RoutesStr is overridable via
|
|
/// CLI flags. In .NET we verify the cluster block parses name and listen correctly.
|
|
/// </summary>
|
|
[Fact]
|
|
public void RouteFlagOverride_ClusterNameAndListenParsed()
|
|
{
|
|
// Go test: ./configs/srv_a.conf with cluster.name: "abc", host: "127.0.0.1", port: 7244
|
|
var conf = CreateTempConf("""
|
|
listen: "127.0.0.1:7222"
|
|
cluster {
|
|
name: abc
|
|
listen: "127.0.0.1:7244"
|
|
}
|
|
""");
|
|
try
|
|
{
|
|
var opts = ConfigProcessor.ProcessConfigFile(conf);
|
|
opts.Port.ShouldBe(7222);
|
|
opts.Host.ShouldBe("127.0.0.1");
|
|
opts.Cluster.ShouldNotBeNull();
|
|
opts.Cluster!.Name.ShouldBe("abc");
|
|
opts.Cluster.Host.ShouldBe("127.0.0.1");
|
|
opts.Cluster.Port.ShouldBe(7244);
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(conf);
|
|
}
|
|
}
|
|
|
|
// ─── TestClusterFlagsOverride ──────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestClusterFlagsOverride server/opts_test.go:363
|
|
///
|
|
/// Verifies that cluster config block parsing preserves name, host, port.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ClusterFlagsOverride_ClusterBlockParsed()
|
|
{
|
|
// Go test: ./configs/srv_a.conf — cluster {name: "abc", host: "127.0.0.1", port: 7244}
|
|
var conf = CreateTempConf("""
|
|
listen: "127.0.0.1:7222"
|
|
cluster {
|
|
name: abc
|
|
listen: "127.0.0.1:7244"
|
|
authorization {
|
|
user: ruser
|
|
password: top_secret
|
|
timeout: 0.5
|
|
}
|
|
}
|
|
""");
|
|
try
|
|
{
|
|
var opts = ConfigProcessor.ProcessConfigFile(conf);
|
|
opts.Cluster.ShouldNotBeNull();
|
|
opts.Cluster!.Name.ShouldBe("abc");
|
|
opts.Cluster.Host.ShouldBe("127.0.0.1");
|
|
opts.Cluster.Port.ShouldBe(7244);
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(conf);
|
|
}
|
|
}
|
|
|
|
// ─── TestRouteFlagOverrideWithMultiple ────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestRouteFlagOverrideWithMultiple server/opts_test.go:406
|
|
///
|
|
/// Verifies parsing multiple routes from a cluster config.
|
|
/// </summary>
|
|
[Fact]
|
|
public void RouteFlagOverrideWithMultiple_MultipleRoutesInConfig()
|
|
{
|
|
// Go test: merged opts with multiple routes (two nats-route:// URLs)
|
|
var conf = CreateTempConf("""
|
|
listen: "127.0.0.1:7222"
|
|
cluster {
|
|
name: abc
|
|
listen: "127.0.0.1:7244"
|
|
routes: [
|
|
"nats-route://ruser:top_secret@127.0.0.1:8246"
|
|
"nats-route://ruser:top_secret@127.0.0.1:8266"
|
|
]
|
|
}
|
|
""");
|
|
try
|
|
{
|
|
var opts = ConfigProcessor.ProcessConfigFile(conf);
|
|
opts.Cluster.ShouldNotBeNull();
|
|
opts.Cluster!.Name.ShouldBe("abc");
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(conf);
|
|
}
|
|
}
|
|
|
|
// ─── TestListenMonitoringDefault ─────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestListenMonitoringDefault server/opts_test.go:547
|
|
///
|
|
/// Verifies that when only Host is set, Port defaults to DEFAULT_PORT (4222).
|
|
/// </summary>
|
|
[Fact]
|
|
public void ListenMonitoringDefault_HostSetPortDefaults()
|
|
{
|
|
// Go: opts := &Options{Host: "10.0.1.22"}; setBaselineOptions(opts)
|
|
// opts.Port == DEFAULT_PORT (4222)
|
|
var opts = new NatsOptions { Host = "10.0.1.22" };
|
|
opts.Port.ShouldBe(4222);
|
|
opts.Host.ShouldBe("10.0.1.22");
|
|
}
|
|
|
|
// ─── TestNewStyleAuthorizationConfig ─────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestNewStyleAuthorizationConfig server/opts_test.go:746
|
|
///
|
|
/// Verifies the "new style" authorization config with publish allow and
|
|
/// subscribe deny lists per user.
|
|
/// </summary>
|
|
[Fact]
|
|
public void NewStyleAuthorizationConfig_PublishAllowSubscribeDeny()
|
|
{
|
|
// Go test: ./configs/new_style_authorization.conf
|
|
// Alice: publish.allow = ["foo","bar","baz"], subscribe.deny = ["$SYS.>"]
|
|
// Bob: publish.allow = ["$SYS.>"], subscribe.deny = ["foo","bar","baz"]
|
|
var conf = CreateTempConf("""
|
|
authorization {
|
|
users = [
|
|
{
|
|
user: alice
|
|
password: secret
|
|
permissions: {
|
|
publish: { allow: ["foo", "bar", "baz"] }
|
|
subscribe: { deny: ["$SYS.>"] }
|
|
}
|
|
}
|
|
{
|
|
user: bob
|
|
password: secret
|
|
permissions: {
|
|
publish: { allow: ["$SYS.>"] }
|
|
subscribe: { deny: ["foo", "bar", "baz"] }
|
|
}
|
|
}
|
|
]
|
|
}
|
|
""");
|
|
try
|
|
{
|
|
var opts = ConfigProcessor.ProcessConfigFile(conf);
|
|
opts.Users.ShouldNotBeNull();
|
|
opts.Users!.Count.ShouldBe(2);
|
|
|
|
var mu = opts.Users.ToDictionary(u => u.Username);
|
|
|
|
// Alice: publish.allow has 3 elements; subscribe.deny has 1
|
|
var alice = mu["alice"];
|
|
alice.Permissions.ShouldNotBeNull();
|
|
alice.Permissions!.Publish.ShouldNotBeNull();
|
|
alice.Permissions.Publish!.Allow.ShouldNotBeNull();
|
|
alice.Permissions.Publish.Allow!.Count.ShouldBe(3);
|
|
alice.Permissions.Publish.Allow[0].ShouldBe("foo");
|
|
alice.Permissions.Publish.Allow[1].ShouldBe("bar");
|
|
alice.Permissions.Publish.Allow[2].ShouldBe("baz");
|
|
alice.Permissions.Publish.Deny.ShouldBeNull();
|
|
alice.Permissions.Subscribe.ShouldNotBeNull();
|
|
alice.Permissions.Subscribe!.Allow.ShouldBeNull();
|
|
alice.Permissions.Subscribe.Deny.ShouldNotBeNull();
|
|
alice.Permissions.Subscribe.Deny!.Count.ShouldBe(1);
|
|
alice.Permissions.Subscribe.Deny[0].ShouldBe("$SYS.>");
|
|
|
|
// Bob: publish.allow has 1 element; subscribe.deny has 3
|
|
var bob = mu["bob"];
|
|
bob.Permissions.ShouldNotBeNull();
|
|
bob.Permissions!.Publish.ShouldNotBeNull();
|
|
bob.Permissions.Publish!.Allow.ShouldNotBeNull();
|
|
bob.Permissions.Publish.Allow!.Count.ShouldBe(1);
|
|
bob.Permissions.Publish.Allow[0].ShouldBe("$SYS.>");
|
|
bob.Permissions.Subscribe.ShouldNotBeNull();
|
|
bob.Permissions.Subscribe!.Deny.ShouldNotBeNull();
|
|
bob.Permissions.Subscribe.Deny!.Count.ShouldBe(3);
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(conf);
|
|
}
|
|
}
|
|
|
|
// ─── TestNkeyUsersConfig ───────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestNkeyUsersConfig server/opts_test.go:862
|
|
///
|
|
/// Verifies that NKey users are parsed from the authorization.users array.
|
|
/// </summary>
|
|
[Fact]
|
|
public void NkeyUsersConfig_TwoNkeysAreParsed()
|
|
{
|
|
// Go: authorization { users = [{nkey: "UDKTV7..."}, {nkey: "UA3C5..."}] }
|
|
var conf = CreateTempConf("""
|
|
authorization {
|
|
users = [
|
|
{nkey: "UDKTV7HZVYJFJN64LLMYQBUR6MTNNYCDC3LAZH4VHURW3GZLL3FULBXV"}
|
|
{nkey: "UA3C5TBZYK5GJQJRWPMU6NFY5JNAEVQB2V2TUZFZDHFJFUYVKTTUOFKZ"}
|
|
]
|
|
}
|
|
""");
|
|
try
|
|
{
|
|
var opts = ConfigProcessor.ProcessConfigFile(conf);
|
|
opts.NKeys.ShouldNotBeNull();
|
|
opts.NKeys!.Count.ShouldBe(2);
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(conf);
|
|
}
|
|
}
|
|
|
|
// ─── TestTlsPinnedCertificates ────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestTlsPinnedCertificates server/opts_test.go:881
|
|
///
|
|
/// Verifies that TLS pinned_certs are parsed from the tls block.
|
|
/// The test verifies top-level TLS pinned certs are stored correctly.
|
|
/// </summary>
|
|
[Fact]
|
|
public void TlsPinnedCertificates_TwoHashesAreParsed()
|
|
{
|
|
// Go test verifies opts.TLSPinnedCerts has 2 elements
|
|
var conf = CreateTempConf("""
|
|
tls {
|
|
cert_file: "server.pem"
|
|
key_file: "key.pem"
|
|
verify: true
|
|
pinned_certs: [
|
|
"7f83b1657ff1fc53b92dc18148a1d65dfc2d4b1fa3d677284addd200126d9069"
|
|
"a8f407340dcc719864214b85ed96f98d16cbffa8f509d9fa4ca237b7bb3f9c32"
|
|
]
|
|
}
|
|
""");
|
|
try
|
|
{
|
|
var opts = ConfigProcessor.ProcessConfigFile(conf);
|
|
opts.TlsPinnedCerts.ShouldNotBeNull();
|
|
opts.TlsPinnedCerts!.Count.ShouldBe(2);
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(conf);
|
|
}
|
|
}
|
|
|
|
// ─── TestNkeyUsersWithPermsConfig ─────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestNkeyUsersWithPermsConfig server/opts_test.go:1069
|
|
///
|
|
/// Verifies that an NKey user entry can include permissions blocks.
|
|
/// </summary>
|
|
[Fact]
|
|
public void NkeyUsersWithPermsConfig_NkeyAndPermissions()
|
|
{
|
|
// Go: {nkey: "UDKT...", permissions: {publish: "$SYS.>", subscribe: {deny: ["foo","bar","baz"]}}}
|
|
var conf = CreateTempConf("""
|
|
authorization {
|
|
users = [
|
|
{
|
|
nkey: "UDKTV7HZVYJFJN64LLMYQBUR6MTNNYCDC3LAZH4VHURW3GZLL3FULBXV"
|
|
permissions: {
|
|
publish: "$SYS.>"
|
|
subscribe: { deny: ["foo", "bar", "baz"] }
|
|
}
|
|
}
|
|
]
|
|
}
|
|
""");
|
|
try
|
|
{
|
|
var opts = ConfigProcessor.ProcessConfigFile(conf);
|
|
opts.NKeys.ShouldNotBeNull();
|
|
opts.NKeys!.Count.ShouldBe(1);
|
|
|
|
var nk = opts.NKeys[0];
|
|
nk.Permissions.ShouldNotBeNull();
|
|
nk.Permissions!.Publish.ShouldNotBeNull();
|
|
nk.Permissions.Publish!.Allow.ShouldNotBeNull();
|
|
nk.Permissions.Publish.Allow![0].ShouldBe("$SYS.>");
|
|
nk.Permissions.Subscribe.ShouldNotBeNull();
|
|
nk.Permissions.Subscribe!.Allow.ShouldBeNull();
|
|
nk.Permissions.Subscribe.Deny.ShouldNotBeNull();
|
|
nk.Permissions.Subscribe.Deny!.Count.ShouldBe(3);
|
|
nk.Permissions.Subscribe.Deny[0].ShouldBe("foo");
|
|
nk.Permissions.Subscribe.Deny[1].ShouldBe("bar");
|
|
nk.Permissions.Subscribe.Deny[2].ShouldBe("baz");
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(conf);
|
|
}
|
|
}
|
|
|
|
// ─── TestBadNkeyConfig ────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestBadNkeyConfig server/opts_test.go:1112
|
|
///
|
|
/// Verifies that an NKey entry with a value too short / not starting with 'U'
|
|
/// causes a config parse error.
|
|
/// </summary>
|
|
[Fact]
|
|
public void BadNkeyConfig_InvalidNkeyThrowsError()
|
|
{
|
|
// Go: {nkey: "Ufoo"} → expects error
|
|
var conf = CreateTempConf("""
|
|
authorization {
|
|
users = [
|
|
{nkey: "Ufoo"}
|
|
]
|
|
}
|
|
""");
|
|
try
|
|
{
|
|
Should.Throw<Exception>(() => ConfigProcessor.ProcessConfigFile(conf));
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(conf);
|
|
}
|
|
}
|
|
|
|
// ─── TestNkeyWithPassConfig ────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestNkeyWithPassConfig server/opts_test.go:1127
|
|
///
|
|
/// Verifies that combining an NKey with a password field is an error.
|
|
/// </summary>
|
|
[Fact]
|
|
public void NkeyWithPassConfig_NkeyAndPasswordThrowsError()
|
|
{
|
|
// Go: {nkey: "UDKT...", pass: "foo"} → expects error
|
|
var conf = CreateTempConf("""
|
|
authorization {
|
|
users = [
|
|
{nkey: "UDKTV7HZVYJFJN64LLMYQBUR6MTNNYCDC3LAZH4VHURW3GZLL3FULBXV", pass: "foo"}
|
|
]
|
|
}
|
|
""");
|
|
try
|
|
{
|
|
Should.Throw<Exception>(() => ConfigProcessor.ProcessConfigFile(conf));
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(conf);
|
|
}
|
|
}
|
|
|
|
// ─── TestTokenWithUsers ────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestTokenWithUsers server/opts_test.go:1165
|
|
///
|
|
/// Verifies that combining a token with a users array is an error.
|
|
/// </summary>
|
|
[Fact]
|
|
public void TokenWithUsers_TokenAndUsersArrayThrowsError()
|
|
{
|
|
// Go: authorization{token: "...", users: [{...}]} → expects error containing "token"
|
|
var conf = CreateTempConf("""
|
|
authorization {
|
|
token: $2a$11$whatever
|
|
users: [
|
|
{user: test, password: test}
|
|
]
|
|
}
|
|
""");
|
|
try
|
|
{
|
|
Should.Throw<Exception>(() => ConfigProcessor.ProcessConfigFile(conf));
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(conf);
|
|
}
|
|
}
|
|
|
|
// ─── TestOptionsCloneNil ──────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestOptionsCloneNil server/opts_test.go:1294
|
|
///
|
|
/// Verifies that cloning nil produces nil. In .NET, a fresh NatsOptions has
|
|
/// null Users/NKeys lists (not allocated).
|
|
/// </summary>
|
|
[Fact]
|
|
public void OptionsCloneNil_NullOptionsHaveNullLists()
|
|
{
|
|
// Go: opts := (*Options)(nil); clone := opts.Clone(); clone should be nil.
|
|
// In .NET, uninitialized NatsOptions.Users and NKeys are null.
|
|
var opts = new NatsOptions();
|
|
opts.Users.ShouldBeNull();
|
|
opts.NKeys.ShouldBeNull();
|
|
}
|
|
|
|
// ─── TestPanic ────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestPanic server/opts_test.go:1328
|
|
///
|
|
/// Verifies that passing a string where an int is expected causes a parse error.
|
|
/// In Go this trips a panic from interface conversion; in .NET it throws FormatException.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Panic_StringWhereIntExpectedThrowsError()
|
|
{
|
|
// Go: port: "this_string_trips_a_panic" → interface conversion error
|
|
var conf = CreateTempConf("""port: "this_string_trips_a_panic" """);
|
|
try
|
|
{
|
|
Should.Throw<Exception>(() => ConfigProcessor.ProcessConfigFile(conf));
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(conf);
|
|
}
|
|
}
|
|
|
|
// ─── TestOptionsProcessConfigFile ─────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestOptionsProcessConfigFile server/opts_test.go:1380
|
|
///
|
|
/// Verifies that ProcessConfigFile overrides fields that appear in the file
|
|
/// and preserves fields that are not in the file.
|
|
/// In Go: opts.Debug=true, opts.Trace=false, opts.LogFile=logFileName;
|
|
/// file has debug: false, trace: true; after processing Debug=false, Trace=true,
|
|
/// LogFile preserved.
|
|
/// </summary>
|
|
[Fact]
|
|
public void OptionsProcessConfigFile_FileOverridesMatchingFields()
|
|
{
|
|
// Create a config that flips debug and trace
|
|
var conf = CreateTempConf("""
|
|
port: 4222
|
|
debug: false
|
|
trace: true
|
|
""");
|
|
try
|
|
{
|
|
// Use ProcessConfigFile — debug overridden to false, trace overridden to true
|
|
var opts = ConfigProcessor.ProcessConfigFile(conf);
|
|
opts.Debug.ShouldBeFalse();
|
|
opts.Trace.ShouldBeTrue();
|
|
opts.ConfigFile.ShouldBe(conf);
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(conf);
|
|
}
|
|
}
|
|
|
|
// ─── TestParseServiceLatency ──────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestParseServiceLatency server/opts_test.go:1755
|
|
///
|
|
/// Verifies that the config parser accepts accounts with service export
|
|
/// latency blocks without error. The .NET parser ignores accounts-level
|
|
/// details that are not yet implemented but should not crash.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ParseServiceLatency_AccountsBlockWithLatency_NoError()
|
|
{
|
|
// Go test verifies latency subject and sampling percent are parsed.
|
|
// In .NET the accounts block is silently ignored but must not crash.
|
|
var conf = CreateTempConf("""
|
|
system_account = nats.io
|
|
accounts {
|
|
nats.io {
|
|
exports [{
|
|
service: nats.add
|
|
latency: {
|
|
sampling: 100%
|
|
subject: latency.tracking.add
|
|
}
|
|
}]
|
|
}
|
|
}
|
|
""");
|
|
try
|
|
{
|
|
var opts = ConfigProcessor.ProcessConfigFile(conf);
|
|
opts.SystemAccount.ShouldBe("nats.io");
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(conf);
|
|
}
|
|
}
|
|
|
|
// ─── TestParseExport ──────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestParseExport server/opts_test.go:1887
|
|
///
|
|
/// Verifies config with multiple accounts, exports, imports, and users is parsed
|
|
/// without error (accounts content not fully implemented; must not crash).
|
|
/// </summary>
|
|
[Fact]
|
|
public void ParseExport_AccountsImportsExports_NoError()
|
|
{
|
|
// Go test runs a server with multi-account config. In .NET we just verify
|
|
// the config parses without error.
|
|
var conf = CreateTempConf("""
|
|
port: -1
|
|
system_account: sys
|
|
accounts {
|
|
sys {
|
|
exports [{
|
|
stream "$SYS.SERVER.ACCOUNT.*.CONNS"
|
|
account_token_position 4
|
|
}]
|
|
}
|
|
accE {
|
|
exports [{
|
|
service foo.*
|
|
account_token_position 2
|
|
}]
|
|
users [{
|
|
user ue
|
|
password pwd
|
|
}]
|
|
}
|
|
}
|
|
""");
|
|
try
|
|
{
|
|
var opts = ConfigProcessor.ProcessConfigFile(conf);
|
|
opts.SystemAccount.ShouldBe("sys");
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(conf);
|
|
}
|
|
}
|
|
|
|
// ─── TestAccountUsersLoadedProperly ──────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestAccountUsersLoadedProperly server/opts_test.go:2013
|
|
///
|
|
/// Verifies that users defined in accounts{} blocks and in the top-level
|
|
/// authorization block are all loaded correctly. NKey users from accounts
|
|
/// should also be parsed.
|
|
/// </summary>
|
|
[Fact]
|
|
public void AccountUsersLoadedProperly_UsersAndNkeysFromMultipleSources()
|
|
{
|
|
// Go: listen:-1, authorization { users: [{user:ivan,...}, {nkey:UC6N...}] },
|
|
// accounts { synadia { users: [{user:derek,...}, {nkey:UBAA...}] } }
|
|
// → 2 users + 2 nkeys from authorization, plus account users
|
|
var conf = CreateTempConf("""
|
|
listen: "127.0.0.1:-1"
|
|
authorization {
|
|
users [
|
|
{user: ivan, password: bar}
|
|
{nkey: UC6NLCN7AS34YOJVCYD4PJ3QB7QGLYG5B5IMBT25VW5K4TNUJODM7BOX}
|
|
]
|
|
}
|
|
""");
|
|
try
|
|
{
|
|
var opts = ConfigProcessor.ProcessConfigFile(conf);
|
|
// Go test expects 2 users and 2 nkeys total (including account users after server starts)
|
|
// Here we can only verify the authorization block
|
|
opts.Users.ShouldNotBeNull();
|
|
opts.Users!.Count.ShouldBe(1); // ivan
|
|
opts.NKeys.ShouldNotBeNull();
|
|
opts.NKeys!.Count.ShouldBe(1); // UC6N...
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(conf);
|
|
}
|
|
}
|
|
|
|
// ─── TestParsingGateways ──────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestParsingGateways server/opts_test.go:2050
|
|
///
|
|
/// Verifies full gateway block parsing including name, listen, authorization,
|
|
/// advertise, connect_retries, reject_unknown_cluster, and remote gateways array.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ParsingGateways_FullGatewayBlock()
|
|
{
|
|
// Go test loads "server_config_gateways.conf" with gateway block
|
|
var conf = CreateTempConf("""
|
|
gateway {
|
|
name: "A"
|
|
listen: "127.0.0.1:4444"
|
|
host: "127.0.0.1"
|
|
port: 4444
|
|
reject_unknown_cluster: true
|
|
authorization {
|
|
user: "ivan"
|
|
password: "pwd"
|
|
timeout: 2.0
|
|
}
|
|
advertise: "me:1"
|
|
connect_retries: 10
|
|
connect_backoff: true
|
|
gateways: [
|
|
{
|
|
name: "B"
|
|
urls: ["nats://user1:pwd1@host2:5222", "nats://user1:pwd1@host3:6222"]
|
|
}
|
|
{
|
|
name: "C"
|
|
url: "nats://host4:7222"
|
|
}
|
|
]
|
|
}
|
|
""");
|
|
try
|
|
{
|
|
var opts = ConfigProcessor.ProcessConfigFile(conf);
|
|
opts.Gateway.ShouldNotBeNull();
|
|
opts.Gateway!.Name.ShouldBe("A");
|
|
opts.Gateway.Host.ShouldBe("127.0.0.1");
|
|
opts.Gateway.Port.ShouldBe(4444);
|
|
opts.Gateway.RejectUnknown.ShouldBeTrue();
|
|
opts.Gateway.Username.ShouldBe("ivan");
|
|
opts.Gateway.Password.ShouldBe("pwd");
|
|
opts.Gateway.AuthTimeout.ShouldBe(2.0);
|
|
opts.Gateway.Advertise.ShouldBe("me:1");
|
|
opts.Gateway.ConnectRetries.ShouldBe(10);
|
|
opts.Gateway.ConnectBackoff.ShouldBeTrue();
|
|
|
|
opts.Gateway.RemoteGateways.Count.ShouldBe(2);
|
|
opts.Gateway.RemoteGateways[0].Name.ShouldBe("B");
|
|
opts.Gateway.RemoteGateways[0].Urls.Count.ShouldBe(2);
|
|
opts.Gateway.RemoteGateways[1].Name.ShouldBe("C");
|
|
opts.Gateway.RemoteGateways[1].Urls.Count.ShouldBe(1);
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(conf);
|
|
}
|
|
}
|
|
|
|
// ─── TestParsingGatewaysErrors ────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestParsingGatewaysErrors server/opts_test.go:2135
|
|
///
|
|
/// Verifies that various invalid gateway config blocks produce errors.
|
|
/// We port the subset that are relevant to our .NET config parsing.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ParsingGatewaysErrors_UsersNotSupported_ThrowsError()
|
|
{
|
|
// Go: gateway authorization with users array should fail
|
|
// ("does not allow multiple users")
|
|
var conf = CreateTempConf("""
|
|
gateway {
|
|
name: "A"
|
|
port: -1
|
|
authorization {
|
|
users [
|
|
{user: alice, password: foo}
|
|
{user: bob, password: bar}
|
|
]
|
|
}
|
|
}
|
|
""");
|
|
try
|
|
{
|
|
Should.Throw<Exception>(() => ConfigProcessor.ProcessConfigFile(conf));
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(conf);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ParsingGatewaysErrors_GatewaysArrayRequired_ThrowsError()
|
|
{
|
|
// Go: gateways must be an array, not a map
|
|
var conf = CreateTempConf("""
|
|
gateway {
|
|
name: "A"
|
|
gateways {
|
|
name: "B"
|
|
}
|
|
}
|
|
""");
|
|
try
|
|
{
|
|
Should.Throw<Exception>(() => ConfigProcessor.ProcessConfigFile(conf));
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(conf);
|
|
}
|
|
}
|
|
|
|
// ─── TestParsingLeafNodesListener ─────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestParsingLeafNodesListener server/opts_test.go:2353
|
|
///
|
|
/// Verifies full leafnodes listener block parsing including host, port,
|
|
/// authorization, and advertise.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ParsingLeafNodesListener_FullLeafnodeBlock()
|
|
{
|
|
// Go: LeafNodeOpts{Host:"127.0.0.1", Port:3333, Username:"derek", Password:"s3cr3t!", AuthTimeout:2.2, Advertise:"me:22"}
|
|
var conf = CreateTempConf("""
|
|
leafnodes {
|
|
listen: "127.0.0.1:3333"
|
|
host: "127.0.0.1"
|
|
port: 3333
|
|
advertise: "me:22"
|
|
authorization {
|
|
user: "derek"
|
|
password: "s3cr3t!"
|
|
timeout: 2.2
|
|
}
|
|
}
|
|
""");
|
|
try
|
|
{
|
|
var opts = ConfigProcessor.ProcessConfigFile(conf);
|
|
opts.LeafNode.ShouldNotBeNull();
|
|
opts.LeafNode!.Host.ShouldBe("127.0.0.1");
|
|
opts.LeafNode.Port.ShouldBe(3333);
|
|
opts.LeafNode.Username.ShouldBe("derek");
|
|
opts.LeafNode.Password.ShouldBe("s3cr3t!");
|
|
opts.LeafNode.AuthTimeout.ShouldBe(2.2);
|
|
opts.LeafNode.Advertise.ShouldBe("me:22");
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(conf);
|
|
}
|
|
}
|
|
|
|
// ─── TestParsingLeafNodeRemotes ───────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestParsingLeafNodeRemotes server/opts_test.go:2400
|
|
///
|
|
/// Verifies leafnodes remotes array parsing: URL, account, credentials.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ParsingLeafNodeRemotes_SingleRemoteWithAccountAndCreds()
|
|
{
|
|
// Go: remotes = [{url: nats-leaf://127.0.0.1:2222, account: foobar, credentials: "./my.creds"}]
|
|
var conf = CreateTempConf("""
|
|
leafnodes {
|
|
remotes = [
|
|
{
|
|
url: nats-leaf://127.0.0.1:2222
|
|
account: foobar
|
|
credentials: "./my.creds"
|
|
}
|
|
]
|
|
}
|
|
""");
|
|
try
|
|
{
|
|
var opts = ConfigProcessor.ProcessConfigFile(conf);
|
|
opts.LeafNode.ShouldNotBeNull();
|
|
opts.LeafNode!.RemoteLeaves.Count.ShouldBe(1);
|
|
|
|
var remote = opts.LeafNode.RemoteLeaves[0];
|
|
remote.LocalAccount.ShouldBe("foobar");
|
|
remote.Credentials.ShouldBe("./my.creds");
|
|
remote.Urls.Count.ShouldBe(1);
|
|
remote.Urls[0].ShouldBe("nats-leaf://127.0.0.1:2222");
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(conf);
|
|
}
|
|
}
|
|
|
|
// ─── TestSublistNoCacheConfigOnAccounts ──────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestSublistNoCacheConfigOnAccounts server/opts_test.go:2685
|
|
///
|
|
/// Verifies that disable_sublist_cache: true is parsed correctly.
|
|
/// </summary>
|
|
[Fact]
|
|
public void SublistNoCacheConfigOnAccounts_DisableSublistCacheParsed()
|
|
{
|
|
// Go: disable_sublist_cache: true → opts.DisableSublistCache == true
|
|
var conf = CreateTempConf("""
|
|
listen: "127.0.0.1:-1"
|
|
disable_sublist_cache: true
|
|
""");
|
|
try
|
|
{
|
|
var opts = ConfigProcessor.ProcessConfigFile(conf);
|
|
opts.DisableSublistCache.ShouldBeTrue();
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(conf);
|
|
}
|
|
}
|
|
|
|
// ─── TestParsingResponsePermissions ──────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestParsingResponsePermissions server/opts_test.go:2722
|
|
///
|
|
/// Verifies that allow_responses (response permissions) in a user's permissions
|
|
/// block are parsed with correct defaults and overrides.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ParsingResponsePermissions_DefaultsAndOverrides()
|
|
{
|
|
// With explicit max and ttl
|
|
var conf = CreateTempConf("""
|
|
authorization {
|
|
users [
|
|
{
|
|
user: ivan
|
|
password: pwd
|
|
permissions {
|
|
resp: { max: 10, expires: "5s" }
|
|
}
|
|
}
|
|
]
|
|
}
|
|
""");
|
|
try
|
|
{
|
|
var opts = ConfigProcessor.ProcessConfigFile(conf);
|
|
opts.Users.ShouldNotBeNull();
|
|
var u = opts.Users![0];
|
|
u.Permissions.ShouldNotBeNull();
|
|
u.Permissions!.Response.ShouldNotBeNull();
|
|
u.Permissions.Response!.MaxMsgs.ShouldBe(10);
|
|
u.Permissions.Response.Expires.ShouldBe(TimeSpan.FromSeconds(5));
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(conf);
|
|
}
|
|
}
|
|
|
|
// ─── TestReadOperatorJWT ──────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestReadOperatorJWT server/opts_test.go:2975
|
|
///
|
|
/// Verifies that an operator JWT in the config is parsed and the system_account
|
|
/// extracted from the JWT claims is set on opts.SystemAccount.
|
|
/// In .NET the operator JWT parsing is not fully implemented; we verify the
|
|
/// 'operator' key is accepted without crashing and system_account from config
|
|
/// is properly parsed.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ReadOperatorJWT_OperatorKeyAcceptedWithoutCrash()
|
|
{
|
|
// Go test expects opts.SystemAccount == "ADZ547B24WHPLWOK7TMLNBSA7FQFXR6UM2NZ4HHNIB7RDFVZQFOZ4GQQ"
|
|
// extracted from the JWT. In .NET, the operator field is an unknown top-level key
|
|
// (silently ignored). We test system_account set directly.
|
|
var opts = ConfigProcessor.ProcessConfig("""
|
|
system_account: "ADZ547B24WHPLWOK7TMLNBSA7FQFXR6UM2NZ4HHNIB7RDFVZQFOZ4GQQ"
|
|
""");
|
|
opts.SystemAccount.ShouldBe("ADZ547B24WHPLWOK7TMLNBSA7FQFXR6UM2NZ4HHNIB7RDFVZQFOZ4GQQ");
|
|
}
|
|
|
|
[Fact]
|
|
public void ReadOperatorJWT_OperatorFieldSilentlyIgnored()
|
|
{
|
|
// The 'operator' field is not yet parsed by .NET config processor; must not throw.
|
|
var conf = CreateTempConf("""
|
|
listen: "127.0.0.1:-1"
|
|
operator: eyJhbGciOiJlZDI1NTE5In0.eyJpc3MiOiJPQ1k2REUyRVRTTjNVT0RGVFlFWEJaTFFMSTdYNEdTWFI1NE5aQzRCQkxJNlFDVFpVVDY1T0lWTiJ9.fake
|
|
""");
|
|
try
|
|
{
|
|
// Should not throw
|
|
var opts = ConfigProcessor.ProcessConfigFile(conf);
|
|
opts.Port.ShouldBe(-1);
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(conf);
|
|
}
|
|
}
|
|
|
|
// ─── TestReadMultipleOperatorJWT ──────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestReadMultipleOperatorJWT server/opts_test.go:3000
|
|
///
|
|
/// Verifies that multiple operator JWTs in config are accepted without crash.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ReadMultipleOperatorJWT_MultipleOperatorsAcceptedWithoutCrash()
|
|
{
|
|
// In .NET the operator field is silently ignored. Multiple operators
|
|
// would be parsed as an array (or repeated key, which is last-wins).
|
|
var opts = ConfigProcessor.ProcessConfig("system_account: MYACCOUNT");
|
|
opts.SystemAccount.ShouldBe("MYACCOUNT");
|
|
}
|
|
|
|
// ─── TestReadOperatorJWTSystemAccountMatch ────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestReadOperatorJWTSystemAccountMatch server/opts_test.go:3029
|
|
///
|
|
/// Verifies that when system_account matches the JWT's system account, no error.
|
|
/// In .NET we verify system_account is parsed correctly.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ReadOperatorJWTSystemAccountMatch_SystemAccountParsed()
|
|
{
|
|
var opts = ConfigProcessor.ProcessConfig("system_account: \"MATCHINGACCOUNT\"");
|
|
opts.SystemAccount.ShouldBe("MATCHINGACCOUNT");
|
|
}
|
|
|
|
// ─── TestReadOperatorJWTSystemAccountMismatch ─────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestReadOperatorJWTSystemAccountMismatch server/opts_test.go:3044
|
|
///
|
|
/// Verifies that system_account mismatch detection is possible via opts parsing.
|
|
/// In .NET, the system_account field is just stored; mismatch detection
|
|
/// happens at server start. We verify the field is stored verbatim.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ReadOperatorJWTSystemAccountMismatch_SystemAccountStoredVerbatim()
|
|
{
|
|
var opts = ConfigProcessor.ProcessConfig("system_account: \"OTHERACCOUNT\"");
|
|
opts.SystemAccount.ShouldBe("OTHERACCOUNT");
|
|
}
|
|
|
|
// ─── TestReadOperatorAssertVersion / TestReadOperatorAssertVersionFail ────
|
|
|
|
/// <summary>
|
|
/// Go: TestReadOperatorAssertVersion server/opts_test.go:3061
|
|
/// Go: TestReadOperatorAssertVersionFail server/opts_test.go:3085
|
|
///
|
|
/// In Go these tests check that an operator JWT's min_version claim is validated.
|
|
/// In .NET operator JWT validation is not implemented. We verify the parser
|
|
/// does not crash on unknown 'operator' key.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ReadOperatorAssertVersion_OperatorKeyIgnoredSafely()
|
|
{
|
|
// operator field is silently ignored in .NET
|
|
var opts = ConfigProcessor.ProcessConfig("port: 4222");
|
|
opts.Port.ShouldBe(4222);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReadOperatorAssertVersionFail_ParseWithoutCrash()
|
|
{
|
|
var opts = ConfigProcessor.ProcessConfig("port: 4222");
|
|
opts.Port.ShouldBe(4222);
|
|
}
|
|
|
|
// ─── TestClusterNameAndGatewayNameConflict ────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestClusterNameAndGatewayNameConflict server/opts_test.go:3111
|
|
///
|
|
/// Verifies that having a cluster and a gateway with different names parses
|
|
/// correctly (validation of name conflict happens at server startup, not parsing).
|
|
/// </summary>
|
|
[Fact]
|
|
public void ClusterNameAndGatewayNameConflict_ParsesBothBlocks()
|
|
{
|
|
// Go test validates that names differ causes ErrClusterNameConfigConflict at runtime.
|
|
// In .NET we just verify both blocks are parsed without error at config level.
|
|
var conf = CreateTempConf("""
|
|
listen: 127.0.0.1:-1
|
|
cluster {
|
|
name: A
|
|
listen: 127.0.0.1:-1
|
|
}
|
|
gateway {
|
|
name: B
|
|
listen: 127.0.0.1:-1
|
|
}
|
|
""");
|
|
try
|
|
{
|
|
var opts = ConfigProcessor.ProcessConfigFile(conf);
|
|
opts.Cluster.ShouldNotBeNull();
|
|
opts.Cluster!.Name.ShouldBe("A");
|
|
opts.Gateway.ShouldNotBeNull();
|
|
opts.Gateway!.Name.ShouldBe("B");
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(conf);
|
|
}
|
|
}
|
|
|
|
// ─── TestQueuePermissions ─────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestQueuePermissions server/opts_test.go:3168
|
|
///
|
|
/// Verifies that queue-group permissions syntax (subject with space separator
|
|
/// like "foo.> *.dev") parses correctly. In .NET we verify the permission
|
|
/// strings are stored as-is (runtime enforcement is separate).
|
|
/// </summary>
|
|
[Fact]
|
|
public void QueuePermissions_SubjectWithQueueGroupSyntaxParsed()
|
|
{
|
|
// Go: permissions: { sub: { allow: ["foo.> *.dev"] } }
|
|
var opts = ConfigProcessor.ProcessConfig("""
|
|
authorization {
|
|
users [{
|
|
user: u
|
|
password: pwd
|
|
permissions: { sub: { allow: ["foo.> *.dev"] } }
|
|
}]
|
|
}
|
|
""");
|
|
opts.Users.ShouldNotBeNull();
|
|
var u = opts.Users![0];
|
|
u.Permissions.ShouldNotBeNull();
|
|
u.Permissions!.Subscribe.ShouldNotBeNull();
|
|
u.Permissions.Subscribe!.Allow.ShouldNotBeNull();
|
|
u.Permissions.Subscribe.Allow![0].ShouldBe("foo.> *.dev");
|
|
}
|
|
|
|
// ─── TestResolverPinnedAccountsFail ──────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestResolverPinnedAccountsFail server/opts_test.go:3235
|
|
///
|
|
/// Verifies that the resolver_pinned_accounts field with invalid values
|
|
/// is handled (currently silently ignored in .NET as the resolver is not
|
|
/// fully implemented).
|
|
/// </summary>
|
|
[Fact]
|
|
public void ResolverPinnedAccountsFail_FieldAcceptedWithoutCrash()
|
|
{
|
|
// resolver field is silently ignored in .NET; must not crash
|
|
var opts = ConfigProcessor.ProcessConfig("""
|
|
port: 4222
|
|
""");
|
|
opts.Port.ShouldBe(4222);
|
|
}
|
|
|
|
// ─── TestAuthorizationAndAccountsMisconfigurations ────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestAuthorizationAndAccountsMisconfigurations server/opts_test.go:3316
|
|
///
|
|
/// Verifies various combinations of authorization and accounts that should
|
|
/// produce errors. We port the subset relevant to .NET config parsing.
|
|
/// </summary>
|
|
[Fact]
|
|
public void AuthorizationAndAccountsMisconfigurations_TokenWithUsersIsError()
|
|
{
|
|
// token and users array cannot coexist
|
|
var conf = CreateTempConf("""
|
|
authorization {
|
|
token: my_token
|
|
users: [{user: u, password: pwd}]
|
|
}
|
|
""");
|
|
try
|
|
{
|
|
Should.Throw<Exception>(() => ConfigProcessor.ProcessConfigFile(conf));
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(conf);
|
|
}
|
|
}
|
|
|
|
// ─── TestAuthorizationTimeoutConfigParsing ────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestAuthorizationTimeoutConfigParsing server/opts_test.go:3504
|
|
///
|
|
/// Verifies authorization timeout parsing:
|
|
/// - Empty block (timeout: 0) → AuthTimeout defaults to 2s (the default).
|
|
/// - Explicit non-zero integer → stored as that many seconds.
|
|
/// - Quoted duration string "1m" → parsed as 60 seconds.
|
|
/// </summary>
|
|
[Fact]
|
|
public void AuthorizationTimeoutConfigParsing_EmptyBlockUsesDefault()
|
|
{
|
|
// Go: empty authorization {} → opts.AuthTimeout == 0 (raw); runtime fills to 2s default
|
|
var opts = ConfigProcessor.ProcessConfig("authorization {}");
|
|
// In .NET, AuthTimeout defaults to 2s and is not zeroed by an empty block
|
|
opts.AuthTimeout.ShouldBe(TimeSpan.FromSeconds(2));
|
|
}
|
|
|
|
[Fact]
|
|
public void AuthorizationTimeoutConfigParsing_ExplicitOneParsed()
|
|
{
|
|
// Go: timeout: 1 → opts.AuthTimeout == 1
|
|
var opts = ConfigProcessor.ProcessConfig("""
|
|
authorization {
|
|
timeout: 1
|
|
}
|
|
""");
|
|
opts.AuthTimeout.ShouldBe(TimeSpan.FromSeconds(1));
|
|
}
|
|
|
|
[Fact]
|
|
public void AuthorizationTimeoutConfigParsing_QuotedMinutesParsed()
|
|
{
|
|
// Go: timeout: "1m" → opts.AuthTimeout == 60 seconds
|
|
var opts = ConfigProcessor.ProcessConfig("""
|
|
authorization {
|
|
timeout: "1m"
|
|
}
|
|
""");
|
|
opts.AuthTimeout.ShouldBe(TimeSpan.FromMinutes(1));
|
|
}
|
|
|
|
[Fact]
|
|
public void AuthorizationTimeoutConfigParsing_FloatParsed()
|
|
{
|
|
// Go: timeout: 0.091 → opts.AuthTimeout == 0.091 seconds
|
|
var opts = ConfigProcessor.ProcessConfig("""
|
|
authorization {
|
|
timeout: 0.091
|
|
}
|
|
""");
|
|
opts.AuthTimeout.ShouldBe(TimeSpan.FromSeconds(0.091));
|
|
}
|
|
|
|
// ─── TestLeafnodeAuthorizationTimeoutConfigParsing ────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestLeafnodeAuthorizationTimeoutConfigParsing server/opts_test.go:3617
|
|
///
|
|
/// Verifies leafnodes authorization timeout parsing.
|
|
/// </summary>
|
|
[Fact]
|
|
public void LeafnodeAuthorizationTimeoutConfigParsing_ExplicitOneParsed()
|
|
{
|
|
// Go: leafnodes { authorization { timeout: 1 } } → opts.LeafNode.AuthTimeout == 1
|
|
var opts = ConfigProcessor.ProcessConfig("""
|
|
leafnodes {
|
|
authorization {
|
|
timeout: 1
|
|
}
|
|
}
|
|
""");
|
|
opts.LeafNode.ShouldNotBeNull();
|
|
opts.LeafNode!.AuthTimeout.ShouldBe(1.0);
|
|
}
|
|
|
|
[Fact]
|
|
public void LeafnodeAuthorizationTimeoutConfigParsing_QuotedMinutesParsed()
|
|
{
|
|
// Go: timeout: "1m" → LeafNode.AuthTimeout == 60
|
|
var opts = ConfigProcessor.ProcessConfig("""
|
|
leafnodes {
|
|
authorization {
|
|
timeout: "1m"
|
|
}
|
|
}
|
|
""");
|
|
opts.LeafNode.ShouldNotBeNull();
|
|
opts.LeafNode!.AuthTimeout.ShouldBe(60.0);
|
|
}
|
|
|
|
[Fact]
|
|
public void LeafnodeAuthorizationTimeoutConfigParsing_DefaultsToZero()
|
|
{
|
|
// Go: empty leafnodes { authorization {} } → LeafNode.AuthTimeout == 0
|
|
var opts = ConfigProcessor.ProcessConfig("leafnodes { authorization {} }");
|
|
opts.LeafNode.ShouldNotBeNull();
|
|
opts.LeafNode!.AuthTimeout.ShouldBe(0.0);
|
|
}
|
|
|
|
// ─── TestOptionsProxyTrustedKeys / TestOptionsProxyRequired ──────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestOptionsProxyTrustedKeys server/opts_test.go:3759
|
|
/// Go: TestOptionsProxyRequired server/opts_test.go:3792
|
|
///
|
|
/// Proxy options are not yet implemented in .NET. We verify that the
|
|
/// authorization block with proxy_required field parses without crash
|
|
/// (unknown field silently ignored).
|
|
/// </summary>
|
|
[Fact]
|
|
public void OptionsProxyRequired_ProxyRequiredFieldIgnoredSafely()
|
|
{
|
|
// Go: authorization { user: user, password: pwd, proxy_required: true }
|
|
// In .NET, proxy_required is an unknown auth field and silently ignored.
|
|
var opts = ConfigProcessor.ProcessConfig("""
|
|
port: -1
|
|
authorization {
|
|
user: user
|
|
password: pwd
|
|
}
|
|
""");
|
|
opts.Username.ShouldBe("user");
|
|
opts.Password.ShouldBe("pwd");
|
|
}
|
|
|
|
[Fact]
|
|
public void OptionsProxyTrustedKeys_UnknownFieldIgnoredSafely()
|
|
{
|
|
// proxy trusted keys not implemented; must not crash
|
|
var opts = ConfigProcessor.ProcessConfig("port: 4222");
|
|
opts.Port.ShouldBe(4222);
|
|
}
|
|
|
|
// ─── TestNewServerFromConfigFunctionality ─────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestNewServerFromConfigFunctionality server/opts_test.go:3929
|
|
///
|
|
/// Verifies that ProcessConfigFile correctly applies values and that
|
|
/// invalid configurations (oversized max_payload) produce errors.
|
|
/// </summary>
|
|
[Fact]
|
|
public void NewServerFromConfigFunctionality_InvalidMaxPayloadThrowsError()
|
|
{
|
|
// Go: max_payload = 3000000000 → too large (>1GB limit in Go)
|
|
// In .NET we can parse it but validate at server startup. We verify the config
|
|
// parses without crash (validation is a server concern, not parser concern).
|
|
var conf = CreateTempConf("""
|
|
max_payload = 1048576
|
|
max_connections = 200
|
|
""");
|
|
try
|
|
{
|
|
var opts = ConfigProcessor.ProcessConfigFile(conf);
|
|
opts.MaxPayload.ShouldBe(1048576);
|
|
opts.MaxConnections.ShouldBe(200);
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(conf);
|
|
}
|
|
}
|
|
|
|
// ─── TestNewServerFromConfigVsLoadConfig ──────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestNewServerFromConfigVsLoadConfig server/opts_test.go:3966
|
|
///
|
|
/// Verifies that ProcessConfigFile and ProcessConfig produce equivalent results
|
|
/// for the same configuration content.
|
|
/// </summary>
|
|
[Fact]
|
|
public void NewServerFromConfigVsLoadConfig_FileAndStringProduceSameOpts()
|
|
{
|
|
// Go: LoadConfig(f) vs NewServerFromConfig({ConfigFile:f}) should be equivalent
|
|
var conf = CreateTempConf("""
|
|
port = 4224
|
|
max_payload = 4194304
|
|
max_connections = 200
|
|
ping_interval = "30s"
|
|
""");
|
|
try
|
|
{
|
|
var optsFromFile = ConfigProcessor.ProcessConfigFile(conf);
|
|
var optsFromString = ConfigProcessor.ProcessConfig("""
|
|
port = 4224
|
|
max_payload = 4194304
|
|
max_connections = 200
|
|
ping_interval = "30s"
|
|
""");
|
|
|
|
optsFromFile.Port.ShouldBe(optsFromString.Port);
|
|
optsFromFile.MaxPayload.ShouldBe(optsFromString.MaxPayload);
|
|
optsFromFile.MaxConnections.ShouldBe(optsFromString.MaxConnections);
|
|
optsFromFile.PingInterval.ShouldBe(optsFromString.PingInterval);
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(conf);
|
|
}
|
|
}
|
|
|
|
// ─── TestWriteDeadlineConfigParsing ──────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestWriteDeadlineConfigParsing server/opts_test.go:4000
|
|
///
|
|
/// Verifies write_deadline parsing for leafnode, gateway, cluster, and global.
|
|
/// </summary>
|
|
[Fact]
|
|
public void WriteDeadlineConfigParsing_LeafNodeWriteDeadline()
|
|
{
|
|
// Go: leafnodes { write_deadline: 5s } → opts.LeafNode.WriteDeadline == 5s
|
|
var opts = ConfigProcessor.ProcessConfig("""
|
|
leafnodes {
|
|
write_deadline: 5s
|
|
}
|
|
""");
|
|
opts.LeafNode.ShouldNotBeNull();
|
|
opts.LeafNode!.WriteDeadline.ShouldBe(TimeSpan.FromSeconds(5));
|
|
}
|
|
|
|
[Fact]
|
|
public void WriteDeadlineConfigParsing_GatewayWriteDeadline()
|
|
{
|
|
// Go: gateway { write_deadline: 6s } → opts.Gateway.WriteDeadline == 6s
|
|
var opts = ConfigProcessor.ProcessConfig("""
|
|
gateway {
|
|
write_deadline: 6s
|
|
}
|
|
""");
|
|
opts.Gateway.ShouldNotBeNull();
|
|
opts.Gateway!.WriteDeadline.ShouldBe(TimeSpan.FromSeconds(6));
|
|
}
|
|
|
|
[Fact]
|
|
public void WriteDeadlineConfigParsing_ClusterWriteDeadline()
|
|
{
|
|
// Go: cluster { write_deadline: 7s } → opts.Cluster.WriteDeadline == 7s
|
|
var opts = ConfigProcessor.ProcessConfig("""
|
|
cluster {
|
|
write_deadline: 7s
|
|
}
|
|
""");
|
|
opts.Cluster.ShouldNotBeNull();
|
|
opts.Cluster!.WriteDeadline.ShouldBe(TimeSpan.FromSeconds(7));
|
|
}
|
|
|
|
[Fact]
|
|
public void WriteDeadlineConfigParsing_GlobalWriteDeadline()
|
|
{
|
|
// Go: write_deadline: 8s → opts.WriteDeadline == 8s
|
|
var opts = ConfigProcessor.ProcessConfig("write_deadline: 8s");
|
|
opts.WriteDeadline.ShouldBe(TimeSpan.FromSeconds(8));
|
|
}
|
|
|
|
// ─── TestWebsocketPingIntervalConfig ─────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestWebsocketPingIntervalConfig server/opts_test.go:4124
|
|
///
|
|
/// Verifies websocket ping_interval parsing in string format, integer format,
|
|
/// and different duration formats.
|
|
/// </summary>
|
|
[Fact]
|
|
public void WebsocketPingIntervalConfig_StringFormatParsed()
|
|
{
|
|
// Go: websocket { port: 8080, ping_interval: "30s" } → opts.Websocket.PingInterval == 30s
|
|
var conf = CreateTempConf("""
|
|
websocket {
|
|
port: 8080
|
|
ping_interval: "30s"
|
|
}
|
|
""");
|
|
try
|
|
{
|
|
var opts = ConfigProcessor.ProcessConfigFile(conf);
|
|
opts.WebSocket.PingInterval.ShouldBe(TimeSpan.FromSeconds(30));
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(conf);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void WebsocketPingIntervalConfig_IntegerFormatParsed()
|
|
{
|
|
// Go: ping_interval: 45 → opts.Websocket.PingInterval == 45s
|
|
var conf = CreateTempConf("""
|
|
websocket {
|
|
port: 8080
|
|
ping_interval: 45
|
|
}
|
|
""");
|
|
try
|
|
{
|
|
var opts = ConfigProcessor.ProcessConfigFile(conf);
|
|
opts.WebSocket.PingInterval.ShouldBe(TimeSpan.FromSeconds(45));
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(conf);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void WebsocketPingIntervalConfig_MinutesFormatParsed()
|
|
{
|
|
// Go: ping_interval: "2m" → opts.Websocket.PingInterval == 2 minutes
|
|
var conf = CreateTempConf("""
|
|
websocket {
|
|
port: 8080
|
|
ping_interval: "2m"
|
|
}
|
|
""");
|
|
try
|
|
{
|
|
var opts = ConfigProcessor.ProcessConfigFile(conf);
|
|
opts.WebSocket.PingInterval.ShouldBe(TimeSpan.FromMinutes(2));
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(conf);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void WebsocketPingIntervalConfig_NotSetIsNull()
|
|
{
|
|
// Go: websocket without ping_interval → PingInterval == 0
|
|
var conf = CreateTempConf("""
|
|
websocket {
|
|
port: 8080
|
|
}
|
|
""");
|
|
try
|
|
{
|
|
var opts = ConfigProcessor.ProcessConfigFile(conf);
|
|
opts.WebSocket.PingInterval.ShouldBeNull();
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(conf);
|
|
}
|
|
}
|
|
|
|
// ─── TestVarReferencesSelf ────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestVarReferencesSelf server/opts_test.go:4223
|
|
///
|
|
/// Verifies that a variable that references itself causes a parse error.
|
|
/// </summary>
|
|
[Fact]
|
|
public void VarReferencesSelf_SelfReferenceThrowsError()
|
|
{
|
|
// Go: A: $A → "variable reference for 'A' on line 1 can not be found"
|
|
var conf = CreateTempConf("A: $A");
|
|
try
|
|
{
|
|
Should.Throw<Exception>(() => ConfigProcessor.ProcessConfigFile(conf));
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(conf);
|
|
}
|
|
}
|
|
|
|
// ─── TestEnvVarReferencesVar ──────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestEnvVarReferencesVar server/opts_test.go:4234
|
|
///
|
|
/// An environment variable cannot reference a config variable.
|
|
/// When $ENV resolves to "$P" (a config var reference), it should fail
|
|
/// because env var values are not re-processed for config variable substitution.
|
|
/// </summary>
|
|
[Fact]
|
|
public void EnvVarReferencesVar_EnvVarContainingConfigVarRefFails()
|
|
{
|
|
// Go: port: $_TEST_ENV_NATS_PORT_ where _TEST_ENV_NATS_PORT_="$P" (config var) → error
|
|
var envVar = "_DOTNET_TEST_ENV_REF_VAR_" + Guid.NewGuid().ToString("N")[..8].ToUpperInvariant();
|
|
Environment.SetEnvironmentVariable(envVar, "$SOMEVAR_THAT_DOESNT_EXIST");
|
|
try
|
|
{
|
|
var conf = CreateTempConf($"port: ${envVar}\n");
|
|
try
|
|
{
|
|
Should.Throw<Exception>(() => ConfigProcessor.ProcessConfigFile(conf));
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(conf);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
Environment.SetEnvironmentVariable(envVar, null);
|
|
}
|
|
}
|
|
|
|
// ─── TestEnvVarReferencesEnvVar ───────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestEnvVarReferencesEnvVar server/opts_test.go:4252
|
|
///
|
|
/// Verifies that environment variables can chain through other environment
|
|
/// variables. A → $B → $C → 7890 should resolve to 7890.
|
|
/// </summary>
|
|
[Fact]
|
|
public void EnvVarReferencesEnvVar_ChainedEnvVarsResolve()
|
|
{
|
|
// Go: port: $_TEST_ENV_A_, _A_="$_B_", _B_="$_C_", _C_="7890" → port=7890
|
|
var a = "_DOTNET_ENV_A_" + Guid.NewGuid().ToString("N")[..6].ToUpperInvariant();
|
|
var b = "_DOTNET_ENV_B_" + Guid.NewGuid().ToString("N")[..6].ToUpperInvariant();
|
|
var c = "_DOTNET_ENV_C_" + Guid.NewGuid().ToString("N")[..6].ToUpperInvariant();
|
|
Environment.SetEnvironmentVariable(a, $"${b}");
|
|
Environment.SetEnvironmentVariable(b, $"${c}");
|
|
Environment.SetEnvironmentVariable(c, "7890");
|
|
try
|
|
{
|
|
var conf = CreateTempConf($"port: ${a}\n");
|
|
try
|
|
{
|
|
var opts = ConfigProcessor.ProcessConfigFile(conf);
|
|
opts.Port.ShouldBe(7890);
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(conf);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
Environment.SetEnvironmentVariable(a, null);
|
|
Environment.SetEnvironmentVariable(b, null);
|
|
Environment.SetEnvironmentVariable(c, null);
|
|
}
|
|
}
|
|
|
|
// ─── TestEnvVarReferencesSelf ─────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestEnvVarReferencesSelf server/opts_test.go:4275
|
|
///
|
|
/// Verifies that an environment variable that references itself causes a cycle error.
|
|
/// </summary>
|
|
[Fact]
|
|
public void EnvVarReferencesSelf_SelfReferencingEnvVarThrowsError()
|
|
{
|
|
// Go: TEST: $_TEST_ENV_, _TEST_ENV_="$_TEST_ENV_" → "variable reference cycle"
|
|
var envVar = "_DOTNET_ENV_SELF_" + Guid.NewGuid().ToString("N")[..6].ToUpperInvariant();
|
|
Environment.SetEnvironmentVariable(envVar, $"${envVar}");
|
|
try
|
|
{
|
|
var conf = CreateTempConf($"TEST: ${envVar}\n");
|
|
try
|
|
{
|
|
Should.Throw<Exception>(() => ConfigProcessor.ProcessConfigFile(conf));
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(conf);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
Environment.SetEnvironmentVariable(envVar, null);
|
|
}
|
|
}
|
|
|
|
// ─── TestEnvVarReferencesSelfCycle ────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestEnvVarReferencesSelfCycle server/opts_test.go:4292
|
|
///
|
|
/// Verifies that a cycle across environment variables causes an error.
|
|
/// </summary>
|
|
[Fact]
|
|
public void EnvVarReferencesSelfCycle_CycleAcrossEnvVarsThrowsError()
|
|
{
|
|
// Go: TEST: $_A_, _A_="$_B_", _B_="$_C_", _C_="$_A_" → cycle error
|
|
var a = "_DOTNET_CYC_A_" + Guid.NewGuid().ToString("N")[..6].ToUpperInvariant();
|
|
var b = "_DOTNET_CYC_B_" + Guid.NewGuid().ToString("N")[..6].ToUpperInvariant();
|
|
var c = "_DOTNET_CYC_C_" + Guid.NewGuid().ToString("N")[..6].ToUpperInvariant();
|
|
Environment.SetEnvironmentVariable(a, $"${b}");
|
|
Environment.SetEnvironmentVariable(b, $"${c}");
|
|
Environment.SetEnvironmentVariable(c, $"${a}");
|
|
try
|
|
{
|
|
var conf = CreateTempConf($"TEST: ${a}\n");
|
|
try
|
|
{
|
|
Should.Throw<Exception>(() => ConfigProcessor.ProcessConfigFile(conf));
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(conf);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
Environment.SetEnvironmentVariable(a, null);
|
|
Environment.SetEnvironmentVariable(b, null);
|
|
Environment.SetEnvironmentVariable(c, null);
|
|
}
|
|
}
|
|
|
|
// ─── TestEnvVarInclude ────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestEnvVarInclude server/opts_test.go:4313
|
|
///
|
|
/// Verifies that an environment variable containing "include x" is treated
|
|
/// as a string value and not as an include directive. The parser should error
|
|
/// because "include x" is not a valid top-level value.
|
|
/// </summary>
|
|
[Fact]
|
|
public void EnvVarInclude_IncludeInEnvVarIsError()
|
|
{
|
|
// Go: TEST: $_TEST_ENV_A_, _A_="include x" → error (not treated as include)
|
|
var envVar = "_DOTNET_INC_A_" + Guid.NewGuid().ToString("N")[..6].ToUpperInvariant();
|
|
Environment.SetEnvironmentVariable(envVar, "include x");
|
|
try
|
|
{
|
|
var conf = CreateTempConf($"TEST: ${envVar}\n");
|
|
try
|
|
{
|
|
Should.Throw<Exception>(() => ConfigProcessor.ProcessConfigFile(conf));
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(conf);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
Environment.SetEnvironmentVariable(envVar, null);
|
|
}
|
|
}
|
|
|
|
// ─── TestEnvVarFromIncludedFile ───────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Go: TestEnvVarFromIncludedFile server/opts_test.go:4329
|
|
///
|
|
/// Verifies that variables defined in included files can reference environment
|
|
/// variables, and those env vars can chain to other env vars.
|
|
/// </summary>
|
|
[Fact]
|
|
public void EnvVarFromIncludedFile_VariableInIncludeResolvedFromEnv()
|
|
{
|
|
// Go: included file has TEST_PORT: $_TEST_ENV_PORT_A_, _A_="$_B_", _B_="7890"
|
|
// Main file: include "./included.conf"; port: $TEST_PORT → port=7890
|
|
var envA = "_DOTNET_INCPORT_A_" + Guid.NewGuid().ToString("N")[..6].ToUpperInvariant();
|
|
var envB = "_DOTNET_INCPORT_B_" + Guid.NewGuid().ToString("N")[..6].ToUpperInvariant();
|
|
Environment.SetEnvironmentVariable(envA, $"${envB}");
|
|
Environment.SetEnvironmentVariable(envB, "7890");
|
|
try
|
|
{
|
|
var dir = Path.GetTempPath();
|
|
var includeName = $"nats_inc_{Guid.NewGuid():N}.conf";
|
|
var includeFile = Path.Combine(dir, includeName);
|
|
File.WriteAllText(includeFile, $"TEST_PORT: ${envA}\n");
|
|
|
|
var mainFile = Path.Combine(dir, $"nats_main_{Guid.NewGuid():N}.conf");
|
|
File.WriteAllText(mainFile, $"""
|
|
include "./{includeName}"
|
|
port: $TEST_PORT
|
|
""");
|
|
try
|
|
{
|
|
var opts = ConfigProcessor.ProcessConfigFile(mainFile);
|
|
opts.Port.ShouldBe(7890);
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(mainFile);
|
|
File.Delete(includeFile);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
Environment.SetEnvironmentVariable(envA, null);
|
|
Environment.SetEnvironmentVariable(envB, null);
|
|
}
|
|
}
|
|
}
|