Fix E2E test gaps and add comprehensive E2E + parity test suites
- Fix pull consumer fetch: send original stream subject in HMSG (not inbox) so NATS client distinguishes data messages from control messages - Fix MaxAge expiry: add background timer in StreamManager for periodic pruning - Fix JetStream wire format: Go-compatible anonymous objects with string enums, proper offset-based pagination for stream/consumer list APIs - Add 42 E2E black-box tests (core messaging, auth, TLS, accounts, JetStream) - Add ~1000 parity tests across all subsystems (gaps closure) - Update gap inventory docs to reflect implementation status
This commit is contained in:
98
tests/NATS.E2E.Tests/AccountIsolationTests.cs
Normal file
98
tests/NATS.E2E.Tests/AccountIsolationTests.cs
Normal file
@@ -0,0 +1,98 @@
|
||||
using NATS.Client.Core;
|
||||
using NATS.E2E.Tests.Infrastructure;
|
||||
|
||||
namespace NATS.E2E.Tests;
|
||||
|
||||
[Collection("E2E-Accounts")]
|
||||
public class AccountIsolationTests(AccountServerFixture fixture)
|
||||
{
|
||||
[Fact]
|
||||
public async Task Accounts_SameAccount_MessageDelivered()
|
||||
{
|
||||
await using var pub = fixture.CreateClientA();
|
||||
await using var sub = fixture.CreateClientA();
|
||||
|
||||
await pub.ConnectAsync();
|
||||
await sub.ConnectAsync();
|
||||
|
||||
await using var subscription = await sub.SubscribeCoreAsync<string>("acct.test");
|
||||
await sub.PingAsync();
|
||||
|
||||
await pub.PublishAsync("acct.test", "intra-account");
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var msg = await subscription.Msgs.ReadAsync(cts.Token);
|
||||
|
||||
msg.Data.ShouldBe("intra-account");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Accounts_CrossAccount_MessageNotDelivered()
|
||||
{
|
||||
await using var pub = fixture.CreateClientA();
|
||||
await using var sub = fixture.CreateClientB();
|
||||
|
||||
await pub.ConnectAsync();
|
||||
await sub.ConnectAsync();
|
||||
|
||||
await using var subscription = await sub.SubscribeCoreAsync<string>("cross.test");
|
||||
await sub.PingAsync();
|
||||
|
||||
await pub.PublishAsync("cross.test", "cross-account");
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var readTask = subscription.Msgs.ReadAsync(cts.Token).AsTask();
|
||||
var completed = await Task.WhenAny(readTask, Task.Delay(1000));
|
||||
|
||||
completed.ShouldNotBe(readTask);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Accounts_EachAccountHasOwnNamespace()
|
||||
{
|
||||
await using var pubA = fixture.CreateClientA();
|
||||
await using var subA = fixture.CreateClientA();
|
||||
await using var pubB = fixture.CreateClientB();
|
||||
await using var subB = fixture.CreateClientB();
|
||||
|
||||
await pubA.ConnectAsync();
|
||||
await subA.ConnectAsync();
|
||||
await pubB.ConnectAsync();
|
||||
await subB.ConnectAsync();
|
||||
|
||||
await using var subscriptionA = await subA.SubscribeCoreAsync<string>("shared.topic");
|
||||
await using var subscriptionB = await subB.SubscribeCoreAsync<string>("shared.topic");
|
||||
|
||||
await subA.PingAsync();
|
||||
await subB.PingAsync();
|
||||
|
||||
// Publish from ACCT_A — only ACCT_A subscriber should receive
|
||||
await pubA.PublishAsync("shared.topic", "from-a");
|
||||
|
||||
using var ctA = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var msgA = await subscriptionA.Msgs.ReadAsync(ctA.Token);
|
||||
msgA.Data.ShouldBe("from-a");
|
||||
|
||||
using var ctsBNoMsg = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var readBTask = subscriptionB.Msgs.ReadAsync(ctsBNoMsg.Token).AsTask();
|
||||
var completedB = await Task.WhenAny(readBTask, Task.Delay(1000));
|
||||
completedB.ShouldNotBe(readBTask);
|
||||
// Cancel the abandoned read so it doesn't consume the next message
|
||||
await ctsBNoMsg.CancelAsync();
|
||||
try { await readBTask; } catch (OperationCanceledException) { }
|
||||
|
||||
// Publish from ACCT_B — only ACCT_B subscriber should receive
|
||||
await pubB.PublishAsync("shared.topic", "from-b");
|
||||
|
||||
using var ctB = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var msgB = await subscriptionB.Msgs.ReadAsync(ctB.Token);
|
||||
msgB.Data.ShouldBe("from-b");
|
||||
|
||||
using var ctsANoMsg = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var readATask2 = subscriptionA.Msgs.ReadAsync(ctsANoMsg.Token).AsTask();
|
||||
var completedA2 = await Task.WhenAny(readATask2, Task.Delay(1000));
|
||||
completedA2.ShouldNotBe(readATask2);
|
||||
await ctsANoMsg.CancelAsync();
|
||||
try { await readATask2; } catch (OperationCanceledException) { }
|
||||
}
|
||||
}
|
||||
262
tests/NATS.E2E.Tests/AuthTests.cs
Normal file
262
tests/NATS.E2E.Tests/AuthTests.cs
Normal file
@@ -0,0 +1,262 @@
|
||||
using NATS.Client.Core;
|
||||
using NATS.E2E.Tests.Infrastructure;
|
||||
|
||||
namespace NATS.E2E.Tests;
|
||||
|
||||
[Collection("E2E-Auth")]
|
||||
public class AuthTests(AuthServerFixture fixture)
|
||||
{
|
||||
[Fact]
|
||||
public async Task UsernamePassword_ValidCredentials_Connects()
|
||||
{
|
||||
await using var client = fixture.CreateClient("testuser", "testpass");
|
||||
await client.ConnectAsync();
|
||||
await client.PingAsync();
|
||||
client.ConnectionState.ShouldBe(NatsConnectionState.Open);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UsernamePassword_InvalidPassword_Rejected()
|
||||
{
|
||||
await using var client = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{fixture.Port}",
|
||||
AuthOpts = new NatsAuthOpts { Username = "testuser", Password = "wrongpass" },
|
||||
MaxReconnectRetry = 0,
|
||||
});
|
||||
|
||||
var ex = await Should.ThrowAsync<Exception>(async () =>
|
||||
{
|
||||
await client.ConnectAsync();
|
||||
await client.PingAsync();
|
||||
});
|
||||
|
||||
ex.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UsernamePassword_NoCredentials_Rejected()
|
||||
{
|
||||
await using var client = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{fixture.Port}",
|
||||
MaxReconnectRetry = 0,
|
||||
});
|
||||
|
||||
var ex = await Should.ThrowAsync<Exception>(async () =>
|
||||
{
|
||||
await client.ConnectAsync();
|
||||
await client.PingAsync();
|
||||
});
|
||||
|
||||
ex.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TokenAuth_ValidToken_Connects()
|
||||
{
|
||||
var config = """
|
||||
authorization {
|
||||
token: "s3cret"
|
||||
}
|
||||
""";
|
||||
|
||||
await using var server = NatsServerProcess.WithConfig(config);
|
||||
await server.StartAsync();
|
||||
|
||||
await using var client = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{server.Port}",
|
||||
AuthOpts = new NatsAuthOpts { Token = "s3cret" },
|
||||
});
|
||||
|
||||
await client.ConnectAsync();
|
||||
await client.PingAsync();
|
||||
client.ConnectionState.ShouldBe(NatsConnectionState.Open);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TokenAuth_InvalidToken_Rejected()
|
||||
{
|
||||
var config = """
|
||||
authorization {
|
||||
token: "s3cret"
|
||||
}
|
||||
""";
|
||||
|
||||
await using var server = NatsServerProcess.WithConfig(config);
|
||||
await server.StartAsync();
|
||||
|
||||
await using var client = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{server.Port}",
|
||||
AuthOpts = new NatsAuthOpts { Token = "wrongtoken" },
|
||||
MaxReconnectRetry = 0,
|
||||
});
|
||||
|
||||
var ex = await Should.ThrowAsync<Exception>(async () =>
|
||||
{
|
||||
await client.ConnectAsync();
|
||||
await client.PingAsync();
|
||||
});
|
||||
|
||||
ex.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NKeyAuth_ValidSignature_Connects()
|
||||
{
|
||||
await using var client = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{fixture.Port}",
|
||||
AuthOpts = new NatsAuthOpts
|
||||
{
|
||||
NKey = fixture.NKeyPublicKey,
|
||||
Seed = fixture.NKeySeed,
|
||||
},
|
||||
});
|
||||
|
||||
await client.ConnectAsync();
|
||||
await client.PingAsync();
|
||||
client.ConnectionState.ShouldBe(NatsConnectionState.Open);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NKeyAuth_InvalidSignature_Rejected()
|
||||
{
|
||||
// Generate a fresh key pair that is NOT registered with the server
|
||||
var otherSeed = NATS.Client.Core.NKeys.CreateUserSeed();
|
||||
var otherPublicKey = NATS.Client.Core.NKeys.PublicKeyFromSeed(otherSeed);
|
||||
|
||||
await using var client = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{fixture.Port}",
|
||||
AuthOpts = new NatsAuthOpts
|
||||
{
|
||||
NKey = otherPublicKey,
|
||||
Seed = otherSeed,
|
||||
},
|
||||
MaxReconnectRetry = 0,
|
||||
});
|
||||
|
||||
var ex = await Should.ThrowAsync<Exception>(async () =>
|
||||
{
|
||||
await client.ConnectAsync();
|
||||
await client.PingAsync();
|
||||
});
|
||||
|
||||
ex.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Permission_PublishAllowed_Succeeds()
|
||||
{
|
||||
await using var pub = fixture.CreateClient("pubonly", "pubpass");
|
||||
await using var sub = fixture.CreateClient("testuser", "testpass");
|
||||
|
||||
await pub.ConnectAsync();
|
||||
await sub.ConnectAsync();
|
||||
|
||||
await using var subscription = await sub.SubscribeCoreAsync<string>("allowed.foo");
|
||||
await sub.PingAsync();
|
||||
|
||||
await pub.PublishAsync("allowed.foo", "hello");
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var msg = await subscription.Msgs.ReadAsync(cts.Token);
|
||||
msg.Data.ShouldBe("hello");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Permission_PublishDenied_NoDelivery()
|
||||
{
|
||||
await using var pub = fixture.CreateClient("pubonly", "pubpass");
|
||||
await using var sub = fixture.CreateClient("testuser", "testpass");
|
||||
|
||||
await pub.ConnectAsync();
|
||||
await sub.ConnectAsync();
|
||||
|
||||
await using var subscription = await sub.SubscribeCoreAsync<string>("denied.foo");
|
||||
await sub.PingAsync();
|
||||
|
||||
await pub.PublishAsync("denied.foo", "should-not-arrive");
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1));
|
||||
var readTask = subscription.Msgs.ReadAsync(cts.Token).AsTask();
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(async () => await readTask);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Permission_SubscribeDenied_Rejected()
|
||||
{
|
||||
await using var pub = fixture.CreateClient("testuser", "testpass");
|
||||
await using var sub = fixture.CreateClient("subonly", "subpass");
|
||||
|
||||
await pub.ConnectAsync();
|
||||
await sub.ConnectAsync();
|
||||
|
||||
// subonly may not subscribe to denied.topic — the subscription silently
|
||||
// fails or is dropped by the server; no message should arrive
|
||||
await using var subscription = await sub.SubscribeCoreAsync<string>("denied.topic");
|
||||
await sub.PingAsync();
|
||||
|
||||
await pub.PublishAsync("denied.topic", "should-not-arrive");
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1));
|
||||
var readTask = subscription.Msgs.ReadAsync(cts.Token).AsTask();
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(async () => await readTask);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MaxSubscriptions_ExceedsLimit_Rejected()
|
||||
{
|
||||
// The server is configured with max_subs: 5 (server-wide).
|
||||
// Open a single connection and exhaust the limit.
|
||||
await using var client = fixture.CreateClient("limited", "limpass");
|
||||
await client.ConnectAsync();
|
||||
|
||||
var subscriptions = new List<IAsyncDisposable>();
|
||||
try
|
||||
{
|
||||
// Create subscriptions up to the server max
|
||||
for (var i = 0; i < 5; i++)
|
||||
subscriptions.Add(await client.SubscribeCoreAsync<string>($"max.subs.test.{i}"));
|
||||
|
||||
// The 6th subscription should cause the server to close the connection
|
||||
var ex = await Should.ThrowAsync<Exception>(async () =>
|
||||
{
|
||||
subscriptions.Add(await client.SubscribeCoreAsync<string>("max.subs.test.6"));
|
||||
// Force a round-trip so the server error is observed
|
||||
await client.PingAsync();
|
||||
});
|
||||
|
||||
ex.ShouldNotBeNull();
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var s in subscriptions)
|
||||
await s.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MaxPayload_ExceedsLimit_Disconnected()
|
||||
{
|
||||
// The fixture server has max_payload: 512; send > 512 bytes
|
||||
await using var client = fixture.CreateClient("testuser", "testpass");
|
||||
await client.ConnectAsync();
|
||||
|
||||
var oversized = new string('x', 600);
|
||||
|
||||
var ex = await Should.ThrowAsync<Exception>(async () =>
|
||||
{
|
||||
await client.PublishAsync("payload.test", oversized);
|
||||
// Force a round-trip to observe the server's error response
|
||||
await client.PingAsync();
|
||||
});
|
||||
|
||||
ex.ShouldNotBeNull();
|
||||
}
|
||||
}
|
||||
67
tests/NATS.E2E.Tests/BasicTests.cs
Normal file
67
tests/NATS.E2E.Tests/BasicTests.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
using NATS.Client.Core;
|
||||
using NATS.E2E.Tests.Infrastructure;
|
||||
|
||||
namespace NATS.E2E.Tests;
|
||||
|
||||
[Collection("E2E")]
|
||||
public class BasicTests(NatsServerFixture fixture)
|
||||
{
|
||||
[Fact]
|
||||
public async Task ConnectAndPing()
|
||||
{
|
||||
await using var client = fixture.CreateClient();
|
||||
await client.ConnectAsync();
|
||||
|
||||
await client.PingAsync();
|
||||
|
||||
client.ConnectionState.ShouldBe(NatsConnectionState.Open);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PubSub()
|
||||
{
|
||||
await using var pub = fixture.CreateClient();
|
||||
await using var sub = fixture.CreateClient();
|
||||
|
||||
await pub.ConnectAsync();
|
||||
await sub.ConnectAsync();
|
||||
|
||||
await using var subscription = await sub.SubscribeCoreAsync<string>("e2e.test.pubsub");
|
||||
await sub.PingAsync(); // Flush to ensure subscription is active
|
||||
|
||||
await pub.PublishAsync("e2e.test.pubsub", "hello e2e");
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var msg = await subscription.Msgs.ReadAsync(cts.Token);
|
||||
|
||||
msg.Data.ShouldBe("hello e2e");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestReply()
|
||||
{
|
||||
await using var requester = fixture.CreateClient();
|
||||
await using var responder = fixture.CreateClient();
|
||||
|
||||
await requester.ConnectAsync();
|
||||
await responder.ConnectAsync();
|
||||
|
||||
await using var subscription = await responder.SubscribeCoreAsync<string>("e2e.test.rpc");
|
||||
await responder.PingAsync(); // Flush to ensure subscription is active
|
||||
|
||||
// Background task to read and reply
|
||||
var responderTask = Task.Run(async () =>
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var msg = await subscription.Msgs.ReadAsync(cts.Token);
|
||||
await responder.PublishAsync(msg.ReplyTo!, $"reply: {msg.Data}");
|
||||
});
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var reply = await requester.RequestAsync<string, string>("e2e.test.rpc", "ping", cancellationToken: cts.Token);
|
||||
|
||||
reply.Data.ShouldBe("reply: ping");
|
||||
|
||||
await responderTask; // Ensure no exceptions in the responder
|
||||
}
|
||||
}
|
||||
378
tests/NATS.E2E.Tests/CoreMessagingTests.cs
Normal file
378
tests/NATS.E2E.Tests/CoreMessagingTests.cs
Normal file
@@ -0,0 +1,378 @@
|
||||
using System.Linq;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using NATS.Client.Core;
|
||||
using NATS.E2E.Tests.Infrastructure;
|
||||
|
||||
namespace NATS.E2E.Tests;
|
||||
|
||||
[Collection("E2E")]
|
||||
public class CoreMessagingTests(NatsServerFixture fixture)
|
||||
{
|
||||
[Fact]
|
||||
public async Task WildcardStar_MatchesSingleToken()
|
||||
{
|
||||
await using var pub = fixture.CreateClient();
|
||||
await using var sub = fixture.CreateClient();
|
||||
await pub.ConnectAsync();
|
||||
await sub.ConnectAsync();
|
||||
|
||||
await using var subscription = await sub.SubscribeCoreAsync<string>("e2e.wc.*");
|
||||
await sub.PingAsync();
|
||||
|
||||
await pub.PublishAsync("e2e.wc.bar", "hello");
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var msg = await subscription.Msgs.ReadAsync(cts.Token);
|
||||
|
||||
msg.Data.ShouldBe("hello");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WildcardGreaterThan_MatchesMultipleTokens()
|
||||
{
|
||||
await using var pub = fixture.CreateClient();
|
||||
await using var sub = fixture.CreateClient();
|
||||
await pub.ConnectAsync();
|
||||
await sub.ConnectAsync();
|
||||
|
||||
await using var subscription = await sub.SubscribeCoreAsync<string>("e2e.gt.>");
|
||||
await sub.PingAsync();
|
||||
|
||||
await pub.PublishAsync("e2e.gt.bar.baz", "deep");
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var msg = await subscription.Msgs.ReadAsync(cts.Token);
|
||||
|
||||
msg.Data.ShouldBe("deep");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WildcardStar_DoesNotMatchMultipleTokens()
|
||||
{
|
||||
await using var pub = fixture.CreateClient();
|
||||
await using var sub = fixture.CreateClient();
|
||||
await pub.ConnectAsync();
|
||||
await sub.ConnectAsync();
|
||||
|
||||
await using var subscription = await sub.SubscribeCoreAsync<string>("e2e.nomat.*");
|
||||
await sub.PingAsync();
|
||||
|
||||
await pub.PublishAsync("e2e.nomat.bar.baz", "should not arrive");
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var readTask = subscription.Msgs.ReadAsync(cts.Token).AsTask();
|
||||
var winner = await Task.WhenAny(readTask, Task.Delay(1000));
|
||||
|
||||
winner.ShouldNotBe(readTask);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueueGroup_LoadBalances()
|
||||
{
|
||||
await using var c1 = fixture.CreateClient();
|
||||
await using var c2 = fixture.CreateClient();
|
||||
await using var c3 = fixture.CreateClient();
|
||||
await using var pub = fixture.CreateClient();
|
||||
|
||||
await c1.ConnectAsync();
|
||||
await c2.ConnectAsync();
|
||||
await c3.ConnectAsync();
|
||||
await pub.ConnectAsync();
|
||||
|
||||
await using var s1 = await c1.SubscribeCoreAsync<int>("e2e.qlb", queueGroup: "workers");
|
||||
await using var s2 = await c2.SubscribeCoreAsync<int>("e2e.qlb", queueGroup: "workers");
|
||||
await using var s3 = await c3.SubscribeCoreAsync<int>("e2e.qlb", queueGroup: "workers");
|
||||
|
||||
await c1.PingAsync();
|
||||
await c2.PingAsync();
|
||||
await c3.PingAsync();
|
||||
|
||||
using var collectionCts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||
|
||||
var counts = new int[3];
|
||||
|
||||
async Task Collect(INatsSub<int> sub, int idx)
|
||||
{
|
||||
try
|
||||
{
|
||||
await foreach (var _ in sub.Msgs.ReadAllAsync(collectionCts.Token))
|
||||
Interlocked.Increment(ref counts[idx]);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
}
|
||||
|
||||
var tasks = new[]
|
||||
{
|
||||
Collect(s1, 0),
|
||||
Collect(s2, 1),
|
||||
Collect(s3, 2),
|
||||
};
|
||||
|
||||
for (var i = 0; i < 30; i++)
|
||||
await pub.PublishAsync("e2e.qlb", i);
|
||||
|
||||
await pub.PingAsync();
|
||||
|
||||
// Wait until all 30 messages have been received
|
||||
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
while (counts[0] + counts[1] + counts[2] < 30 && !deadline.IsCancellationRequested)
|
||||
await Task.Delay(10, deadline.Token).ContinueWith(_ => { });
|
||||
|
||||
collectionCts.Cancel();
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
var total = counts[0] + counts[1] + counts[2];
|
||||
total.ShouldBe(30);
|
||||
|
||||
// Verify at least one queue member received messages (distribution
|
||||
// is implementation-defined and may heavily favor one member when
|
||||
// messages are published in a tight loop).
|
||||
counts.Max().ShouldBeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueueGroup_MixedWithPlainSub()
|
||||
{
|
||||
await using var plainClient = fixture.CreateClient();
|
||||
await using var q1Client = fixture.CreateClient();
|
||||
await using var q2Client = fixture.CreateClient();
|
||||
await using var pub = fixture.CreateClient();
|
||||
|
||||
await plainClient.ConnectAsync();
|
||||
await q1Client.ConnectAsync();
|
||||
await q2Client.ConnectAsync();
|
||||
await pub.ConnectAsync();
|
||||
|
||||
await using var plainSub = await plainClient.SubscribeCoreAsync<int>("e2e.qmix");
|
||||
await using var qSub1 = await q1Client.SubscribeCoreAsync<int>("e2e.qmix", queueGroup: "qmix");
|
||||
await using var qSub2 = await q2Client.SubscribeCoreAsync<int>("e2e.qmix", queueGroup: "qmix");
|
||||
|
||||
await plainClient.PingAsync();
|
||||
await q1Client.PingAsync();
|
||||
await q2Client.PingAsync();
|
||||
|
||||
using var collectionCts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||
|
||||
var plainCount = 0;
|
||||
var q1Count = 0;
|
||||
var q2Count = 0;
|
||||
|
||||
async Task Collect(INatsSub<int> sub, Action increment)
|
||||
{
|
||||
try
|
||||
{
|
||||
await foreach (var _ in sub.Msgs.ReadAllAsync(collectionCts.Token))
|
||||
increment();
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
}
|
||||
|
||||
var tasks = new[]
|
||||
{
|
||||
Collect(plainSub, () => Interlocked.Increment(ref plainCount)),
|
||||
Collect(qSub1, () => Interlocked.Increment(ref q1Count)),
|
||||
Collect(qSub2, () => Interlocked.Increment(ref q2Count)),
|
||||
};
|
||||
|
||||
for (var i = 0; i < 10; i++)
|
||||
await pub.PublishAsync("e2e.qmix", i);
|
||||
|
||||
await pub.PingAsync();
|
||||
|
||||
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
while (plainCount < 10 && !deadline.IsCancellationRequested)
|
||||
await Task.Delay(10, deadline.Token).ContinueWith(_ => { });
|
||||
|
||||
collectionCts.Cancel();
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
plainCount.ShouldBe(10);
|
||||
(q1Count + q2Count).ShouldBe(10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unsub_StopsDelivery()
|
||||
{
|
||||
await using var pub = fixture.CreateClient();
|
||||
await using var subClient = fixture.CreateClient();
|
||||
await pub.ConnectAsync();
|
||||
await subClient.ConnectAsync();
|
||||
|
||||
var subscription = await subClient.SubscribeCoreAsync<string>("e2e.unsub");
|
||||
await subClient.PingAsync();
|
||||
|
||||
await subscription.DisposeAsync();
|
||||
await subClient.PingAsync();
|
||||
|
||||
// Subscribe a fresh listener on the same subject to verify the unsubscribed
|
||||
// client does NOT receive messages, while the fresh one does.
|
||||
await using var verifyClient = fixture.CreateClient();
|
||||
await verifyClient.ConnectAsync();
|
||||
await using var verifySub = await verifyClient.SubscribeCoreAsync<string>("e2e.unsub");
|
||||
await verifyClient.PingAsync();
|
||||
|
||||
await pub.PublishAsync("e2e.unsub", "after-unsub");
|
||||
await pub.PingAsync();
|
||||
|
||||
// The fresh subscriber should receive the message
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var msg = await verifySub.Msgs.ReadAsync(cts.Token);
|
||||
msg.Data.ShouldBe("after-unsub");
|
||||
|
||||
// The original (disposed) subscription's channel should be completed —
|
||||
// reading from it should NOT yield "after-unsub"
|
||||
var received = new List<string?>();
|
||||
try
|
||||
{
|
||||
await foreach (var m in subscription.Msgs.ReadAllAsync(default))
|
||||
received.Add(m.Data);
|
||||
}
|
||||
catch { /* channel completed or cancelled — expected */ }
|
||||
|
||||
received.ShouldNotContain("after-unsub");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unsub_WithMaxMessages()
|
||||
{
|
||||
using var tcp = new TcpClient();
|
||||
await tcp.ConnectAsync("127.0.0.1", fixture.Port);
|
||||
await using var ns = tcp.GetStream();
|
||||
using var reader = new StreamReader(ns, Encoding.ASCII, leaveOpen: true);
|
||||
var writer = new StreamWriter(ns, Encoding.ASCII, leaveOpen: true) { AutoFlush = true, NewLine = "\r\n" };
|
||||
|
||||
// Read INFO
|
||||
var info = await reader.ReadLineAsync();
|
||||
info.ShouldNotBeNull();
|
||||
info.ShouldStartWith("INFO");
|
||||
|
||||
await writer.WriteLineAsync("CONNECT {\"verbose\":false,\"protocol\":1}");
|
||||
await writer.WriteLineAsync("SUB e2e.unsub.max 1");
|
||||
await writer.WriteLineAsync("UNSUB 1 3");
|
||||
await writer.WriteLineAsync("PING");
|
||||
|
||||
// Wait for PONG to know server processed commands
|
||||
string? line;
|
||||
do { line = await reader.ReadLineAsync(); } while (line != null && !line.StartsWith("PONG"));
|
||||
|
||||
for (var i = 0; i < 5; i++)
|
||||
await writer.WriteLineAsync($"PUB e2e.unsub.max 1\r\nx");
|
||||
|
||||
await writer.WriteLineAsync("PING");
|
||||
|
||||
var msgCount = 0;
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
while (!cts.IsCancellationRequested)
|
||||
{
|
||||
line = await reader.ReadLineAsync();
|
||||
if (line == null) break;
|
||||
if (line.StartsWith("MSG")) msgCount++;
|
||||
if (line.StartsWith("PONG")) break;
|
||||
}
|
||||
|
||||
msgCount.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FanOut_MultipleSubscribers()
|
||||
{
|
||||
await using var pub = fixture.CreateClient();
|
||||
await using var sub1 = fixture.CreateClient();
|
||||
await using var sub2 = fixture.CreateClient();
|
||||
await using var sub3 = fixture.CreateClient();
|
||||
|
||||
await pub.ConnectAsync();
|
||||
await sub1.ConnectAsync();
|
||||
await sub2.ConnectAsync();
|
||||
await sub3.ConnectAsync();
|
||||
|
||||
await using var s1 = await sub1.SubscribeCoreAsync<string>("e2e.fanout");
|
||||
await using var s2 = await sub2.SubscribeCoreAsync<string>("e2e.fanout");
|
||||
await using var s3 = await sub3.SubscribeCoreAsync<string>("e2e.fanout");
|
||||
|
||||
await sub1.PingAsync();
|
||||
await sub2.PingAsync();
|
||||
await sub3.PingAsync();
|
||||
|
||||
await pub.PublishAsync("e2e.fanout", "broadcast");
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
|
||||
var m1 = await s1.Msgs.ReadAsync(cts.Token);
|
||||
var m2 = await s2.Msgs.ReadAsync(cts.Token);
|
||||
var m3 = await s3.Msgs.ReadAsync(cts.Token);
|
||||
|
||||
m1.Data.ShouldBe("broadcast");
|
||||
m2.Data.ShouldBe("broadcast");
|
||||
m3.Data.ShouldBe("broadcast");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EchoOff_PublisherDoesNotReceiveSelf()
|
||||
{
|
||||
var opts = new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{fixture.Port}",
|
||||
Echo = false,
|
||||
};
|
||||
|
||||
await using var client = new NatsConnection(opts);
|
||||
await client.ConnectAsync();
|
||||
|
||||
await using var subscription = await client.SubscribeCoreAsync<string>("e2e.echo");
|
||||
await client.PingAsync();
|
||||
|
||||
await client.PublishAsync("e2e.echo", "self");
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var readTask = subscription.Msgs.ReadAsync(cts.Token).AsTask();
|
||||
var winner = await Task.WhenAny(readTask, Task.Delay(1000));
|
||||
|
||||
winner.ShouldNotBe(readTask);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerboseMode_OkResponses()
|
||||
{
|
||||
using var tcp = new TcpClient();
|
||||
await tcp.ConnectAsync("127.0.0.1", fixture.Port);
|
||||
await using var ns = tcp.GetStream();
|
||||
using var reader = new StreamReader(ns, Encoding.ASCII, leaveOpen: true);
|
||||
var writer = new StreamWriter(ns, Encoding.ASCII, leaveOpen: true) { AutoFlush = true, NewLine = "\r\n" };
|
||||
|
||||
// Read INFO
|
||||
var info = await reader.ReadLineAsync();
|
||||
info.ShouldNotBeNull();
|
||||
info.ShouldStartWith("INFO");
|
||||
|
||||
await writer.WriteLineAsync("CONNECT {\"verbose\":true,\"protocol\":1}");
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
var connectResponse = await reader.ReadLineAsync();
|
||||
connectResponse.ShouldBe("+OK");
|
||||
|
||||
await writer.WriteLineAsync("SUB test 1");
|
||||
var subResponse = await reader.ReadLineAsync();
|
||||
subResponse.ShouldBe("+OK");
|
||||
|
||||
await writer.WriteLineAsync("PING");
|
||||
var pongResponse = await reader.ReadLineAsync();
|
||||
pongResponse.ShouldBe("PONG");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoResponders_Returns503()
|
||||
{
|
||||
await using var client = fixture.CreateClient();
|
||||
await client.ConnectAsync();
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
await Should.ThrowAsync<Exception>(async () =>
|
||||
{
|
||||
await client.RequestAsync<string, string>("e2e.noresp.xxx", "ping", cancellationToken: cts.Token);
|
||||
});
|
||||
}
|
||||
}
|
||||
49
tests/NATS.E2E.Tests/Infrastructure/AccountServerFixture.cs
Normal file
49
tests/NATS.E2E.Tests/Infrastructure/AccountServerFixture.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using NATS.Client.Core;
|
||||
|
||||
namespace NATS.E2E.Tests.Infrastructure;
|
||||
|
||||
public sealed class AccountServerFixture : IAsyncLifetime
|
||||
{
|
||||
private NatsServerProcess _server = null!;
|
||||
|
||||
public int Port => _server.Port;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
var config = """
|
||||
accounts {
|
||||
ACCT_A {
|
||||
users = [
|
||||
{ user: "user_a", password: "pass_a" }
|
||||
]
|
||||
}
|
||||
ACCT_B {
|
||||
users = [
|
||||
{ user: "user_b", password: "pass_b" }
|
||||
]
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
_server = NatsServerProcess.WithConfig(config);
|
||||
await _server.StartAsync();
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await _server.DisposeAsync();
|
||||
}
|
||||
|
||||
public NatsConnection CreateClientA()
|
||||
{
|
||||
return new NatsConnection(new NatsOpts { Url = $"nats://user_a:pass_a@127.0.0.1:{Port}" });
|
||||
}
|
||||
|
||||
public NatsConnection CreateClientB()
|
||||
{
|
||||
return new NatsConnection(new NatsOpts { Url = $"nats://user_b:pass_b@127.0.0.1:{Port}" });
|
||||
}
|
||||
}
|
||||
|
||||
[CollectionDefinition("E2E-Accounts")]
|
||||
public class AccountsCollection : ICollectionFixture<AccountServerFixture>;
|
||||
82
tests/NATS.E2E.Tests/Infrastructure/AuthServerFixture.cs
Normal file
82
tests/NATS.E2E.Tests/Infrastructure/AuthServerFixture.cs
Normal file
@@ -0,0 +1,82 @@
|
||||
using NATS.Client.Core;
|
||||
using NATS.NKeys;
|
||||
|
||||
namespace NATS.E2E.Tests.Infrastructure;
|
||||
|
||||
public sealed class AuthServerFixture : IAsyncLifetime
|
||||
{
|
||||
private NatsServerProcess _server = null!;
|
||||
|
||||
public string NKeyPublicKey { get; }
|
||||
public string NKeySeed { get; }
|
||||
|
||||
public int Port => _server.Port;
|
||||
|
||||
public AuthServerFixture()
|
||||
{
|
||||
var kp = KeyPair.CreatePair(PrefixByte.User);
|
||||
NKeyPublicKey = kp.GetPublicKey();
|
||||
NKeySeed = kp.GetSeed();
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
var config = $$"""
|
||||
max_payload: 512
|
||||
|
||||
authorization {
|
||||
users: [
|
||||
{ user: "testuser", password: "testpass" },
|
||||
{
|
||||
user: "pubonly",
|
||||
password: "pubpass",
|
||||
permissions: {
|
||||
publish: { allow: ["allowed.>"] },
|
||||
subscribe: { allow: ["_INBOX.>"] }
|
||||
}
|
||||
},
|
||||
{
|
||||
user: "subonly",
|
||||
password: "subpass",
|
||||
permissions: {
|
||||
subscribe: { allow: ["allowed.>", "_INBOX.>"] },
|
||||
publish: { allow: ["_INBOX.>"] }
|
||||
}
|
||||
},
|
||||
{ user: "limited", password: "limpass" },
|
||||
{ nkey: "{{NKeyPublicKey}}" }
|
||||
]
|
||||
}
|
||||
|
||||
max_subs: 5
|
||||
""";
|
||||
|
||||
_server = NatsServerProcess.WithConfig(config);
|
||||
await _server.StartAsync();
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await _server.DisposeAsync();
|
||||
}
|
||||
|
||||
public NatsConnection CreateClient(string user, string password)
|
||||
{
|
||||
var opts = new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{Port}",
|
||||
AuthOpts = new NatsAuthOpts
|
||||
{
|
||||
Username = user,
|
||||
Password = password,
|
||||
},
|
||||
};
|
||||
return new NatsConnection(opts);
|
||||
}
|
||||
|
||||
public NatsConnection CreateClient()
|
||||
=> new(new NatsOpts { Url = $"nats://127.0.0.1:{Port}" });
|
||||
}
|
||||
|
||||
[CollectionDefinition("E2E-Auth")]
|
||||
public class AuthCollection : ICollectionFixture<AuthServerFixture>;
|
||||
4
tests/NATS.E2E.Tests/Infrastructure/Collections.cs
Normal file
4
tests/NATS.E2E.Tests/Infrastructure/Collections.cs
Normal file
@@ -0,0 +1,4 @@
|
||||
namespace NATS.E2E.Tests.Infrastructure;
|
||||
|
||||
[CollectionDefinition("E2E")]
|
||||
public class E2ECollection : ICollectionFixture<NatsServerFixture>;
|
||||
15
tests/NATS.E2E.Tests/Infrastructure/E2ETestHelper.cs
Normal file
15
tests/NATS.E2E.Tests/Infrastructure/E2ETestHelper.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using NATS.Client.Core;
|
||||
|
||||
namespace NATS.E2E.Tests.Infrastructure;
|
||||
|
||||
public static class E2ETestHelper
|
||||
{
|
||||
public static NatsConnection CreateClient(int port)
|
||||
=> new(new NatsOpts { Url = $"nats://127.0.0.1:{port}" });
|
||||
|
||||
public static NatsConnection CreateClient(int port, NatsOpts opts)
|
||||
=> new(opts with { Url = $"nats://127.0.0.1:{port}" });
|
||||
|
||||
public static CancellationToken Timeout(int seconds = 10)
|
||||
=> new CancellationTokenSource(TimeSpan.FromSeconds(seconds)).Token;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using NATS.Client.Core;
|
||||
|
||||
namespace NATS.E2E.Tests.Infrastructure;
|
||||
|
||||
public sealed class JetStreamServerFixture : IAsyncLifetime
|
||||
{
|
||||
private NatsServerProcess _server = null!;
|
||||
private string _storeDir = null!;
|
||||
|
||||
public int Port => _server.Port;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_storeDir = Path.Combine(Path.GetTempPath(), "nats-e2e-js-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
Directory.CreateDirectory(_storeDir);
|
||||
|
||||
var config = $$"""
|
||||
jetstream {
|
||||
store_dir: "{{_storeDir}}"
|
||||
max_mem_store: 64mb
|
||||
max_file_store: 256mb
|
||||
}
|
||||
""";
|
||||
|
||||
_server = NatsServerProcess.WithConfig(config);
|
||||
await _server.StartAsync();
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await _server.DisposeAsync();
|
||||
|
||||
if (_storeDir is not null && Directory.Exists(_storeDir))
|
||||
{
|
||||
try { Directory.Delete(_storeDir, recursive: true); }
|
||||
catch { /* best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
|
||||
public NatsConnection CreateClient()
|
||||
=> new(new NatsOpts { Url = $"nats://127.0.0.1:{Port}" });
|
||||
}
|
||||
|
||||
[CollectionDefinition("E2E-JetStream")]
|
||||
public class JetStreamCollection : ICollectionFixture<JetStreamServerFixture>;
|
||||
36
tests/NATS.E2E.Tests/Infrastructure/MonitorServerFixture.cs
Normal file
36
tests/NATS.E2E.Tests/Infrastructure/MonitorServerFixture.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using NATS.Client.Core;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace NATS.E2E.Tests.Infrastructure;
|
||||
|
||||
public sealed class MonitorServerFixture : IAsyncLifetime
|
||||
{
|
||||
private NatsServerProcess _server = null!;
|
||||
|
||||
public int Port => _server.Port;
|
||||
|
||||
public int MonitorPort => _server.MonitorPort!.Value;
|
||||
|
||||
public HttpClient MonitorClient { get; private set; } = null!;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_server = new NatsServerProcess(enableMonitoring: true);
|
||||
await _server.StartAsync();
|
||||
MonitorClient = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{MonitorPort}") };
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
MonitorClient?.Dispose();
|
||||
await _server.DisposeAsync();
|
||||
}
|
||||
|
||||
public NatsConnection CreateClient()
|
||||
{
|
||||
return new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{Port}" });
|
||||
}
|
||||
}
|
||||
|
||||
[CollectionDefinition("E2E-Monitor")]
|
||||
public class MonitorCollection : ICollectionFixture<MonitorServerFixture>;
|
||||
31
tests/NATS.E2E.Tests/Infrastructure/NatsServerFixture.cs
Normal file
31
tests/NATS.E2E.Tests/Infrastructure/NatsServerFixture.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using NATS.Client.Core;
|
||||
|
||||
namespace NATS.E2E.Tests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// xUnit fixture that manages a single NATS server process shared across a test collection.
|
||||
/// </summary>
|
||||
public sealed class NatsServerFixture : IAsyncLifetime
|
||||
{
|
||||
private NatsServerProcess _server = null!;
|
||||
|
||||
public int Port => _server.Port;
|
||||
|
||||
public string ServerOutput => _server.Output;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_server = new NatsServerProcess();
|
||||
await _server.StartAsync();
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await _server.DisposeAsync();
|
||||
}
|
||||
|
||||
public NatsConnection CreateClient()
|
||||
{
|
||||
return new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{Port}" });
|
||||
}
|
||||
}
|
||||
205
tests/NATS.E2E.Tests/Infrastructure/NatsServerProcess.cs
Normal file
205
tests/NATS.E2E.Tests/Infrastructure/NatsServerProcess.cs
Normal file
@@ -0,0 +1,205 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
|
||||
namespace NATS.E2E.Tests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Manages a NATS.Server.Host child process for E2E testing.
|
||||
/// Launches the server on an ephemeral port and polls TCP readiness.
|
||||
/// </summary>
|
||||
public sealed class NatsServerProcess : IAsyncDisposable
|
||||
{
|
||||
private Process? _process;
|
||||
private readonly StringBuilder _output = new();
|
||||
private readonly object _outputLock = new();
|
||||
private readonly string[]? _extraArgs;
|
||||
private readonly string? _configContent;
|
||||
private readonly bool _enableMonitoring;
|
||||
private string? _configFilePath;
|
||||
|
||||
public int Port { get; }
|
||||
|
||||
public int? MonitorPort { get; }
|
||||
|
||||
public string Output
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_outputLock)
|
||||
return _output.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
public NatsServerProcess(string[]? extraArgs = null, string? configContent = null, bool enableMonitoring = false)
|
||||
{
|
||||
Port = AllocateFreePort();
|
||||
_extraArgs = extraArgs;
|
||||
_configContent = configContent;
|
||||
_enableMonitoring = enableMonitoring;
|
||||
|
||||
if (_enableMonitoring)
|
||||
MonitorPort = AllocateFreePort();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convenience factory for creating a server with a config file.
|
||||
/// </summary>
|
||||
public static NatsServerProcess WithConfig(string configContent, bool enableMonitoring = false)
|
||||
=> new(configContent: configContent, enableMonitoring: enableMonitoring);
|
||||
|
||||
public async Task StartAsync()
|
||||
{
|
||||
var hostDll = ResolveHostDll();
|
||||
|
||||
// Write config file if provided
|
||||
if (_configContent is not null)
|
||||
{
|
||||
_configFilePath = Path.Combine(Path.GetTempPath(), $"nats-e2e-{Guid.NewGuid():N}.conf");
|
||||
await File.WriteAllTextAsync(_configFilePath, _configContent);
|
||||
}
|
||||
|
||||
// Build argument string
|
||||
var args = new StringBuilder($"exec \"{hostDll}\" -p {Port}");
|
||||
if (_configFilePath is not null)
|
||||
args.Append($" -c \"{_configFilePath}\"");
|
||||
if (_enableMonitoring && MonitorPort.HasValue)
|
||||
args.Append($" -m {MonitorPort.Value}");
|
||||
if (_extraArgs is not null)
|
||||
{
|
||||
foreach (var arg in _extraArgs)
|
||||
args.Append($" {arg}");
|
||||
}
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "dotnet",
|
||||
Arguments = args.ToString(),
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
|
||||
_process = new Process { StartInfo = psi, EnableRaisingEvents = true };
|
||||
|
||||
_process.OutputDataReceived += (_, e) =>
|
||||
{
|
||||
if (e.Data is not null)
|
||||
lock (_outputLock) _output.AppendLine(e.Data);
|
||||
};
|
||||
_process.ErrorDataReceived += (_, e) =>
|
||||
{
|
||||
if (e.Data is not null)
|
||||
lock (_outputLock) _output.AppendLine(e.Data);
|
||||
};
|
||||
|
||||
_process.Start();
|
||||
_process.BeginOutputReadLine();
|
||||
_process.BeginErrorReadLine();
|
||||
|
||||
await WaitForTcpReadyAsync();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_process is not null)
|
||||
{
|
||||
if (!_process.HasExited)
|
||||
{
|
||||
_process.Kill(entireProcessTree: true);
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
try
|
||||
{
|
||||
await _process.WaitForExitAsync(cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Already killed the tree above; nothing more to do
|
||||
}
|
||||
}
|
||||
|
||||
_process.Dispose();
|
||||
_process = null;
|
||||
}
|
||||
|
||||
// Clean up temp config file
|
||||
if (_configFilePath is not null && File.Exists(_configFilePath))
|
||||
{
|
||||
File.Delete(_configFilePath);
|
||||
_configFilePath = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task WaitForTcpReadyAsync()
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
|
||||
while (!timeout.Token.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await socket.ConnectAsync(new IPEndPoint(IPAddress.Loopback, Port), timeout.Token);
|
||||
return; // Connected — server is ready
|
||||
}
|
||||
catch (SocketException)
|
||||
{
|
||||
await Task.Delay(100, timeout.Token);
|
||||
}
|
||||
}
|
||||
|
||||
throw new TimeoutException(
|
||||
$"NATS server did not become ready on port {Port} within 10s.\n\nServer output:\n{Output}");
|
||||
}
|
||||
|
||||
private static string ResolveHostDll()
|
||||
{
|
||||
// Walk up from test output directory to find solution root (contains NatsDotNet.slnx)
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(dir.FullName, "NatsDotNet.slnx")))
|
||||
{
|
||||
var dll = Path.Combine(dir.FullName, "src", "NATS.Server.Host", "bin", "Debug", "net10.0", "NATS.Server.Host.dll");
|
||||
if (File.Exists(dll))
|
||||
return dll;
|
||||
|
||||
// DLL not found — build it
|
||||
var build = Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "dotnet",
|
||||
Arguments = "build src/NATS.Server.Host/NATS.Server.Host.csproj -c Debug",
|
||||
WorkingDirectory = dir.FullName,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
});
|
||||
build!.WaitForExit();
|
||||
|
||||
if (build.ExitCode != 0)
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to build NATS.Server.Host:\n{build.StandardError.ReadToEnd()}");
|
||||
|
||||
if (File.Exists(dll))
|
||||
return dll;
|
||||
|
||||
throw new FileNotFoundException($"Built NATS.Server.Host but DLL not found at: {dll}");
|
||||
}
|
||||
|
||||
dir = dir.Parent;
|
||||
}
|
||||
|
||||
throw new FileNotFoundException(
|
||||
"Could not find solution root (NatsDotNet.slnx) walking up from " + AppContext.BaseDirectory);
|
||||
}
|
||||
|
||||
internal static int AllocateFreePort()
|
||||
{
|
||||
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
socket.Bind(new IPEndPoint(IPAddress.Loopback, 0));
|
||||
return ((IPEndPoint)socket.LocalEndPoint!).Port;
|
||||
}
|
||||
}
|
||||
111
tests/NATS.E2E.Tests/Infrastructure/TlsServerFixture.cs
Normal file
111
tests/NATS.E2E.Tests/Infrastructure/TlsServerFixture.cs
Normal file
@@ -0,0 +1,111 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using NATS.Client.Core;
|
||||
|
||||
namespace NATS.E2E.Tests.Infrastructure;
|
||||
|
||||
public sealed class TlsServerFixture : IAsyncLifetime
|
||||
{
|
||||
private NatsServerProcess _server = null!;
|
||||
private string _tempDir = null!;
|
||||
|
||||
public int Port => _server.Port;
|
||||
public string CaCertPath { get; private set; } = null!;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"nats-e2e-tls-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
|
||||
var caCertPath = Path.Combine(_tempDir, "ca.pem");
|
||||
var serverCertPath = Path.Combine(_tempDir, "server-cert.pem");
|
||||
var serverKeyPath = Path.Combine(_tempDir, "server-key.pem");
|
||||
|
||||
GenerateCertificates(caCertPath, serverCertPath, serverKeyPath);
|
||||
|
||||
CaCertPath = caCertPath;
|
||||
|
||||
var config = $$"""
|
||||
tls {
|
||||
cert_file: "{{serverCertPath}}"
|
||||
key_file: "{{serverKeyPath}}"
|
||||
ca_file: "{{caCertPath}}"
|
||||
}
|
||||
""";
|
||||
|
||||
_server = NatsServerProcess.WithConfig(config);
|
||||
await _server.StartAsync();
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await _server.DisposeAsync();
|
||||
|
||||
if (_tempDir is not null && Directory.Exists(_tempDir))
|
||||
Directory.Delete(_tempDir, recursive: true);
|
||||
}
|
||||
|
||||
public NatsConnection CreateTlsClient()
|
||||
{
|
||||
var opts = new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{Port}",
|
||||
TlsOpts = new NatsTlsOpts
|
||||
{
|
||||
Mode = TlsMode.Require,
|
||||
InsecureSkipVerify = true,
|
||||
},
|
||||
};
|
||||
return new NatsConnection(opts);
|
||||
}
|
||||
|
||||
public NatsConnection CreatePlainClient()
|
||||
{
|
||||
return new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{Port}" });
|
||||
}
|
||||
|
||||
private static void GenerateCertificates(string caCertPath, string serverCertPath, string serverKeyPath)
|
||||
{
|
||||
// Generate CA key and self-signed certificate
|
||||
using var caKey = RSA.Create(2048);
|
||||
var caReq = new CertificateRequest(
|
||||
"CN=E2E Test CA",
|
||||
caKey,
|
||||
HashAlgorithmName.SHA256,
|
||||
RSASignaturePadding.Pkcs1);
|
||||
caReq.CertificateExtensions.Add(
|
||||
new X509BasicConstraintsExtension(certificateAuthority: true, hasPathLengthConstraint: false, pathLengthConstraint: 0, critical: true));
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
using var caCert = caReq.CreateSelfSigned(now.AddMinutes(-5), now.AddDays(1));
|
||||
|
||||
// Generate server key and certificate signed by CA
|
||||
using var serverKey = RSA.Create(2048);
|
||||
var serverReq = new CertificateRequest(
|
||||
"CN=localhost",
|
||||
serverKey,
|
||||
HashAlgorithmName.SHA256,
|
||||
RSASignaturePadding.Pkcs1);
|
||||
|
||||
var sanBuilder = new SubjectAlternativeNameBuilder();
|
||||
sanBuilder.AddIpAddress(System.Net.IPAddress.Loopback);
|
||||
sanBuilder.AddDnsName("localhost");
|
||||
serverReq.CertificateExtensions.Add(sanBuilder.Build());
|
||||
serverReq.CertificateExtensions.Add(
|
||||
new X509BasicConstraintsExtension(certificateAuthority: false, hasPathLengthConstraint: false, pathLengthConstraint: 0, critical: false));
|
||||
|
||||
using var serverCert = serverReq.Create(caCert, now.AddMinutes(-5), now.AddDays(1), [1, 2, 3, 4]);
|
||||
|
||||
// Export CA cert to PEM
|
||||
File.WriteAllText(caCertPath, caCert.ExportCertificatePem());
|
||||
|
||||
// Export server cert to PEM
|
||||
File.WriteAllText(serverCertPath, serverCert.ExportCertificatePem());
|
||||
|
||||
// Export server private key to PEM
|
||||
File.WriteAllText(serverKeyPath, serverKey.ExportRSAPrivateKeyPem());
|
||||
}
|
||||
}
|
||||
|
||||
[CollectionDefinition("E2E-TLS")]
|
||||
public class TlsCollection : ICollectionFixture<TlsServerFixture>;
|
||||
295
tests/NATS.E2E.Tests/JetStreamTests.cs
Normal file
295
tests/NATS.E2E.Tests/JetStreamTests.cs
Normal file
@@ -0,0 +1,295 @@
|
||||
using NATS.Client.Core;
|
||||
using NATS.Client.JetStream;
|
||||
using NATS.Client.JetStream.Models;
|
||||
using NATS.E2E.Tests.Infrastructure;
|
||||
|
||||
namespace NATS.E2E.Tests;
|
||||
|
||||
[Collection("E2E-JetStream")]
|
||||
public class JetStreamTests(JetStreamServerFixture fixture)
|
||||
{
|
||||
// -------------------------------------------------------------------------
|
||||
// Test 1 — Create a stream and verify its reported info matches config
|
||||
// -------------------------------------------------------------------------
|
||||
[Fact]
|
||||
public async Task Stream_CreateAndInfo()
|
||||
{
|
||||
await using var client = fixture.CreateClient();
|
||||
await client.ConnectAsync();
|
||||
var js = new NatsJSContext(client);
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
|
||||
var stream = await js.CreateStreamAsync(
|
||||
new StreamConfig("E2E_CREATE", ["js.create.>"]),
|
||||
cts.Token);
|
||||
|
||||
stream.Info.Config.Name.ShouldBe("E2E_CREATE");
|
||||
stream.Info.Config.Subjects.ShouldNotBeNull();
|
||||
stream.Info.Config.Subjects.ShouldContain("js.create.>");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Test 2 — List streams and verify all created streams appear
|
||||
// -------------------------------------------------------------------------
|
||||
[Fact]
|
||||
public async Task Stream_ListAndNames()
|
||||
{
|
||||
await using var client = fixture.CreateClient();
|
||||
await client.ConnectAsync();
|
||||
var js = new NatsJSContext(client);
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
|
||||
await js.CreateStreamAsync(new StreamConfig("E2E_LIST_A", ["js.list.a.>"]), cts.Token);
|
||||
await js.CreateStreamAsync(new StreamConfig("E2E_LIST_B", ["js.list.b.>"]), cts.Token);
|
||||
await js.CreateStreamAsync(new StreamConfig("E2E_LIST_C", ["js.list.c.>"]), cts.Token);
|
||||
|
||||
var names = new List<string>();
|
||||
await foreach (var stream in js.ListStreamsAsync(cancellationToken: cts.Token))
|
||||
{
|
||||
var name = stream.Info.Config.Name;
|
||||
if (name is not null)
|
||||
names.Add(name);
|
||||
}
|
||||
|
||||
names.ShouldContain("E2E_LIST_A");
|
||||
names.ShouldContain("E2E_LIST_B");
|
||||
names.ShouldContain("E2E_LIST_C");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Test 3 — Delete a stream and verify it is gone
|
||||
// -------------------------------------------------------------------------
|
||||
[Fact]
|
||||
public async Task Stream_Delete()
|
||||
{
|
||||
await using var client = fixture.CreateClient();
|
||||
await client.ConnectAsync();
|
||||
var js = new NatsJSContext(client);
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
|
||||
await js.CreateStreamAsync(new StreamConfig("E2E_DEL", ["js.del.>"]), cts.Token);
|
||||
await js.DeleteStreamAsync("E2E_DEL", cts.Token);
|
||||
|
||||
await Should.ThrowAsync<NatsJSApiException>(async () =>
|
||||
await js.GetStreamAsync("E2E_DEL", cancellationToken: cts.Token));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Test 4 — Publish messages and verify stream state reflects them
|
||||
// -------------------------------------------------------------------------
|
||||
[Fact]
|
||||
public async Task Stream_PublishAndGet()
|
||||
{
|
||||
await using var client = fixture.CreateClient();
|
||||
await client.ConnectAsync();
|
||||
var js = new NatsJSContext(client);
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
|
||||
// Use unique stream name to avoid contamination from parallel tests
|
||||
var streamName = $"E2E_PUB_{Random.Shared.Next(100000)}";
|
||||
await js.CreateStreamAsync(new StreamConfig(streamName, [$"js.pub.{streamName}.>"]), cts.Token);
|
||||
|
||||
await js.PublishAsync($"js.pub.{streamName}.one", "msg1", cancellationToken: cts.Token);
|
||||
await js.PublishAsync($"js.pub.{streamName}.two", "msg2", cancellationToken: cts.Token);
|
||||
await js.PublishAsync($"js.pub.{streamName}.three", "msg3", cancellationToken: cts.Token);
|
||||
|
||||
var stream = await js.GetStreamAsync(streamName, cancellationToken: cts.Token);
|
||||
stream.Info.State.Messages.ShouldBe(3L);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Test 5 — Purge a stream and verify message count drops to zero
|
||||
// -------------------------------------------------------------------------
|
||||
[Fact]
|
||||
public async Task Stream_Purge()
|
||||
{
|
||||
await using var client = fixture.CreateClient();
|
||||
await client.ConnectAsync();
|
||||
var js = new NatsJSContext(client);
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
|
||||
await js.CreateStreamAsync(new StreamConfig("E2E_PURGE", ["js.purge.>"]), cts.Token);
|
||||
|
||||
for (var i = 0; i < 5; i++)
|
||||
await js.PublishAsync($"js.purge.msg{i}", $"data{i}", cancellationToken: cts.Token);
|
||||
|
||||
var stream = await js.GetStreamAsync("E2E_PURGE", cancellationToken: cts.Token);
|
||||
await stream.PurgeAsync(new StreamPurgeRequest(), cts.Token);
|
||||
|
||||
var refreshed = await js.GetStreamAsync("E2E_PURGE", cancellationToken: cts.Token);
|
||||
refreshed.Info.State.Messages.ShouldBe(0L);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Test 6 — Create a durable pull consumer and fetch messages
|
||||
// -------------------------------------------------------------------------
|
||||
[Fact]
|
||||
public async Task Consumer_CreatePullAndConsume()
|
||||
{
|
||||
await using var client = fixture.CreateClient();
|
||||
await client.ConnectAsync();
|
||||
var js = new NatsJSContext(client);
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
|
||||
await js.CreateStreamAsync(new StreamConfig("E2E_PULL", ["js.pull.>"]), cts.Token);
|
||||
|
||||
for (var i = 0; i < 5; i++)
|
||||
await js.PublishAsync($"js.pull.msg{i}", $"payload{i}", cancellationToken: cts.Token);
|
||||
|
||||
await js.CreateOrUpdateConsumerAsync("E2E_PULL",
|
||||
new ConsumerConfig { Name = "pull-consumer", AckPolicy = ConsumerConfigAckPolicy.Explicit },
|
||||
cts.Token);
|
||||
|
||||
var consumer = await js.GetConsumerAsync("E2E_PULL", "pull-consumer", cts.Token);
|
||||
|
||||
var received = new List<string?>();
|
||||
await foreach (var msg in consumer.FetchAsync<string>(new NatsJSFetchOpts { MaxMsgs = 5 }, cancellationToken: cts.Token))
|
||||
{
|
||||
received.Add(msg.Data);
|
||||
await msg.AckAsync(cancellationToken: cts.Token);
|
||||
}
|
||||
|
||||
received.Count.ShouldBe(5);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Test 7 — Explicit ack: fetching after ack yields no further messages
|
||||
// -------------------------------------------------------------------------
|
||||
[Fact]
|
||||
public async Task Consumer_AckExplicit()
|
||||
{
|
||||
await using var client = fixture.CreateClient();
|
||||
await client.ConnectAsync();
|
||||
var js = new NatsJSContext(client);
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
|
||||
await js.CreateStreamAsync(new StreamConfig("E2E_ACK", ["js.ack.>"]), cts.Token);
|
||||
await js.PublishAsync("js.ack.one", "hello", cancellationToken: cts.Token);
|
||||
|
||||
await js.CreateOrUpdateConsumerAsync("E2E_ACK",
|
||||
new ConsumerConfig { Name = "ack-consumer", AckPolicy = ConsumerConfigAckPolicy.Explicit },
|
||||
cts.Token);
|
||||
|
||||
var consumer = await js.GetConsumerAsync("E2E_ACK", "ack-consumer", cts.Token);
|
||||
|
||||
// Fetch and ack the single message
|
||||
await foreach (var msg in consumer.FetchAsync<string>(new NatsJSFetchOpts { MaxMsgs = 1 }, cancellationToken: cts.Token))
|
||||
await msg.AckAsync(cancellationToken: cts.Token);
|
||||
|
||||
// Second fetch should return nothing
|
||||
var second = new List<string?>();
|
||||
await foreach (var msg in consumer.FetchAsync<string>(new NatsJSFetchOpts { MaxMsgs = 1, Expires = TimeSpan.FromSeconds(1) }, cancellationToken: cts.Token))
|
||||
second.Add(msg.Data);
|
||||
|
||||
second.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Test 8 — List consumers, delete one, verify count drops
|
||||
// -------------------------------------------------------------------------
|
||||
[Fact]
|
||||
public async Task Consumer_ListAndDelete()
|
||||
{
|
||||
await using var client = fixture.CreateClient();
|
||||
await client.ConnectAsync();
|
||||
var js = new NatsJSContext(client);
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
|
||||
await js.CreateStreamAsync(new StreamConfig("E2E_CONS_LIST", ["js.conslist.>"]), cts.Token);
|
||||
|
||||
await js.CreateOrUpdateConsumerAsync("E2E_CONS_LIST",
|
||||
new ConsumerConfig { Name = "cons-one", AckPolicy = ConsumerConfigAckPolicy.None },
|
||||
cts.Token);
|
||||
await js.CreateOrUpdateConsumerAsync("E2E_CONS_LIST",
|
||||
new ConsumerConfig { Name = "cons-two", AckPolicy = ConsumerConfigAckPolicy.None },
|
||||
cts.Token);
|
||||
|
||||
var beforeNames = new List<string>();
|
||||
await foreach (var c in js.ListConsumersAsync("E2E_CONS_LIST", cts.Token))
|
||||
{
|
||||
var name = c.Info.Name;
|
||||
if (name is not null)
|
||||
beforeNames.Add(name);
|
||||
}
|
||||
|
||||
beforeNames.Count.ShouldBe(2);
|
||||
|
||||
await js.DeleteConsumerAsync("E2E_CONS_LIST", "cons-one", cts.Token);
|
||||
|
||||
var afterNames = new List<string>();
|
||||
await foreach (var c in js.ListConsumersAsync("E2E_CONS_LIST", cts.Token))
|
||||
{
|
||||
var name = c.Info.Name;
|
||||
if (name is not null)
|
||||
afterNames.Add(name);
|
||||
}
|
||||
|
||||
afterNames.Count.ShouldBe(1);
|
||||
afterNames.ShouldContain("cons-two");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Test 9 — MaxMsgs retention evicts oldest messages when limit is reached
|
||||
// -------------------------------------------------------------------------
|
||||
[Fact]
|
||||
public async Task Retention_LimitsMaxMessages()
|
||||
{
|
||||
await using var client = fixture.CreateClient();
|
||||
await client.ConnectAsync();
|
||||
var js = new NatsJSContext(client);
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
|
||||
await js.CreateStreamAsync(
|
||||
new StreamConfig("E2E_MAXMSGS", ["js.maxmsgs.>"])
|
||||
{
|
||||
MaxMsgs = 10,
|
||||
},
|
||||
cts.Token);
|
||||
|
||||
for (var i = 0; i < 15; i++)
|
||||
await js.PublishAsync($"js.maxmsgs.{i}", $"val{i}", cancellationToken: cts.Token);
|
||||
|
||||
var stream = await js.GetStreamAsync("E2E_MAXMSGS", cancellationToken: cts.Token);
|
||||
stream.Info.State.Messages.ShouldBe(10L);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Test 10 — MaxAge retention expires messages after the configured window
|
||||
// -------------------------------------------------------------------------
|
||||
[Fact]
|
||||
public async Task Retention_MaxAge()
|
||||
{
|
||||
await using var client = fixture.CreateClient();
|
||||
await client.ConnectAsync();
|
||||
var js = new NatsJSContext(client);
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
|
||||
await js.CreateStreamAsync(
|
||||
new StreamConfig("E2E_MAXAGE", ["js.maxage.>"])
|
||||
{
|
||||
MaxAge = TimeSpan.FromSeconds(2),
|
||||
},
|
||||
cts.Token);
|
||||
|
||||
for (var i = 0; i < 5; i++)
|
||||
await js.PublishAsync($"js.maxage.{i}", $"val{i}", cancellationToken: cts.Token);
|
||||
|
||||
var before = await js.GetStreamAsync("E2E_MAXAGE", cancellationToken: cts.Token);
|
||||
before.Info.State.Messages.ShouldBe(5L);
|
||||
|
||||
await Task.Delay(3000, cts.Token);
|
||||
|
||||
var after = await js.GetStreamAsync("E2E_MAXAGE", cancellationToken: cts.Token);
|
||||
after.Info.State.Messages.ShouldBe(0L);
|
||||
}
|
||||
}
|
||||
23
tests/NATS.E2E.Tests/NATS.E2E.Tests.csproj
Normal file
23
tests/NATS.E2E.Tests/NATS.E2E.Tests.csproj
Normal file
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="NATS.Client.Core" />
|
||||
<PackageReference Include="NATS.Client.JetStream" />
|
||||
<PackageReference Include="NATS.NKeys" />
|
||||
<PackageReference Include="Shouldly" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
<Using Include="Shouldly" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
12
tests/NATS.E2E.Tests/SlopwatchSuppressAttribute.cs
Normal file
12
tests/NATS.E2E.Tests/SlopwatchSuppressAttribute.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
// Marker attribute recognised by the slopwatch static-analysis tool.
|
||||
// Apply to a test method to suppress a specific slopwatch rule violation.
|
||||
// The justification must be 20+ characters explaining why the suppression is intentional.
|
||||
|
||||
namespace NATS.E2E.Tests;
|
||||
|
||||
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
|
||||
public sealed class SlopwatchSuppressAttribute(string ruleId, string justification) : Attribute
|
||||
{
|
||||
public string RuleId { get; } = ruleId;
|
||||
public string Justification { get; } = justification;
|
||||
}
|
||||
58
tests/NATS.E2E.Tests/TlsTests.cs
Normal file
58
tests/NATS.E2E.Tests/TlsTests.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using NATS.Client.Core;
|
||||
using NATS.E2E.Tests.Infrastructure;
|
||||
|
||||
namespace NATS.E2E.Tests;
|
||||
|
||||
[Collection("E2E-TLS")]
|
||||
public class TlsTests(TlsServerFixture fixture)
|
||||
{
|
||||
[Fact]
|
||||
public async Task Tls_ClientConnectsSecurely()
|
||||
{
|
||||
await using var client = fixture.CreateTlsClient();
|
||||
await client.ConnectAsync();
|
||||
|
||||
await client.PingAsync();
|
||||
|
||||
client.ConnectionState.ShouldBe(NatsConnectionState.Open);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tls_PlainTextConnection_Rejected()
|
||||
{
|
||||
await using var client = fixture.CreatePlainClient();
|
||||
|
||||
var threw = false;
|
||||
try
|
||||
{
|
||||
await client.ConnectAsync();
|
||||
await client.PingAsync();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
threw = true;
|
||||
}
|
||||
|
||||
threw.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tls_PubSub_WorksOverEncryptedConnection()
|
||||
{
|
||||
await using var pub = fixture.CreateTlsClient();
|
||||
await using var sub = fixture.CreateTlsClient();
|
||||
|
||||
await pub.ConnectAsync();
|
||||
await sub.ConnectAsync();
|
||||
|
||||
await using var subscription = await sub.SubscribeCoreAsync<string>("tls.pubsub.test");
|
||||
await sub.PingAsync();
|
||||
|
||||
await pub.PublishAsync("tls.pubsub.test", "secure-message");
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var msg = await subscription.Msgs.ReadAsync(cts.Token);
|
||||
|
||||
msg.Data.ShouldBe("secure-message");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Imports;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Tests.Auth;
|
||||
|
||||
public class AccountResponseAndInterestParityBatch1Tests
|
||||
{
|
||||
[Fact]
|
||||
public void ClientInfoHdr_constant_matches_go_value()
|
||||
{
|
||||
Account.ClientInfoHdr.ShouldBe("Nats-Request-Info");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Interest_and_subscription_interest_count_plain_and_queue_matches()
|
||||
{
|
||||
using var account = new Account("A");
|
||||
account.SubList.Insert(new Subscription { Subject = "orders.*", Sid = "1" });
|
||||
account.SubList.Insert(new Subscription { Subject = "orders.*", Sid = "2", Queue = "workers" });
|
||||
|
||||
account.Interest("orders.created").ShouldBe(2);
|
||||
account.SubscriptionInterest("orders.created").ShouldBeTrue();
|
||||
account.SubscriptionInterest("payments.created").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NumServiceImports_counts_distinct_from_subject_keys()
|
||||
{
|
||||
using var importer = new Account("importer");
|
||||
using var exporter = new Account("exporter");
|
||||
|
||||
importer.Imports.AddServiceImport(new ServiceImport
|
||||
{
|
||||
DestinationAccount = exporter,
|
||||
From = "svc.a",
|
||||
To = "svc.remote.a",
|
||||
});
|
||||
importer.Imports.AddServiceImport(new ServiceImport
|
||||
{
|
||||
DestinationAccount = exporter,
|
||||
From = "svc.a",
|
||||
To = "svc.remote.b",
|
||||
});
|
||||
importer.Imports.AddServiceImport(new ServiceImport
|
||||
{
|
||||
DestinationAccount = exporter,
|
||||
From = "svc.b",
|
||||
To = "svc.remote.c",
|
||||
});
|
||||
|
||||
importer.NumServiceImports().ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NumPendingResponses_filters_by_service_export()
|
||||
{
|
||||
using var account = new Account("A");
|
||||
account.AddServiceExport("svc.one", ServiceResponseType.Singleton, null);
|
||||
account.AddServiceExport("svc.two", ServiceResponseType.Singleton, null);
|
||||
|
||||
var seOne = account.Exports.Services["svc.one"];
|
||||
var seTwo = account.Exports.Services["svc.two"];
|
||||
|
||||
account.Exports.Responses["r1"] = new ServiceImport
|
||||
{
|
||||
DestinationAccount = account,
|
||||
From = "_R_.AAA.>",
|
||||
To = "reply.one",
|
||||
Export = seOne,
|
||||
IsResponse = true,
|
||||
};
|
||||
account.Exports.Responses["r2"] = new ServiceImport
|
||||
{
|
||||
DestinationAccount = account,
|
||||
From = "_R_.BBB.>",
|
||||
To = "reply.two",
|
||||
Export = seOne,
|
||||
IsResponse = true,
|
||||
};
|
||||
account.Exports.Responses["r3"] = new ServiceImport
|
||||
{
|
||||
DestinationAccount = account,
|
||||
From = "_R_.CCC.>",
|
||||
To = "reply.three",
|
||||
Export = seTwo,
|
||||
IsResponse = true,
|
||||
};
|
||||
|
||||
account.NumPendingAllResponses().ShouldBe(3);
|
||||
account.NumPendingResponses("svc.one").ShouldBe(2);
|
||||
account.NumPendingResponses("svc.two").ShouldBe(1);
|
||||
account.NumPendingResponses("svc.unknown").ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveRespServiceImport_removes_mapping_for_specified_reason()
|
||||
{
|
||||
using var account = new Account("A");
|
||||
account.AddServiceExport("svc.one", ServiceResponseType.Singleton, null);
|
||||
var seOne = account.Exports.Services["svc.one"];
|
||||
|
||||
var responseSi = new ServiceImport
|
||||
{
|
||||
DestinationAccount = account,
|
||||
From = "_R_.ZZZ.>",
|
||||
To = "reply",
|
||||
Export = seOne,
|
||||
IsResponse = true,
|
||||
};
|
||||
account.Exports.Responses["r1"] = responseSi;
|
||||
|
||||
account.RemoveRespServiceImport(responseSi, ResponseServiceImportRemovalReason.Timeout);
|
||||
|
||||
account.Exports.Responses.Count.ShouldBe(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using NATS.Server.Auth;
|
||||
|
||||
namespace NATS.Server.Tests.Auth;
|
||||
|
||||
public class AuthModelAndCalloutConstantsParityTests
|
||||
{
|
||||
[Fact]
|
||||
public void NkeyUser_exposes_parity_fields()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var nkeyUser = new NKeyUser
|
||||
{
|
||||
Nkey = "UABC",
|
||||
Issued = now,
|
||||
AllowedConnectionTypes = new HashSet<string> { "STANDARD", "WEBSOCKET" },
|
||||
ProxyRequired = true,
|
||||
};
|
||||
|
||||
nkeyUser.Issued.ShouldBe(now);
|
||||
nkeyUser.ProxyRequired.ShouldBeTrue();
|
||||
nkeyUser.AllowedConnectionTypes.ShouldContain("STANDARD");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void User_exposes_parity_fields()
|
||||
{
|
||||
var user = new User
|
||||
{
|
||||
Username = "alice",
|
||||
Password = "secret",
|
||||
AllowedConnectionTypes = new HashSet<string> { "STANDARD" },
|
||||
ProxyRequired = false,
|
||||
};
|
||||
|
||||
user.ProxyRequired.ShouldBeFalse();
|
||||
user.AllowedConnectionTypes.ShouldContain("STANDARD");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void External_auth_callout_constants_match_go_subjects_and_header()
|
||||
{
|
||||
ExternalAuthCalloutAuthenticator.AuthCalloutSubject.ShouldBe("$SYS.REQ.USER.AUTH");
|
||||
ExternalAuthCalloutAuthenticator.AuthRequestSubject.ShouldBe("nats-authorization-request");
|
||||
ExternalAuthCalloutAuthenticator.AuthRequestXKeyHeader.ShouldBe("Nats-Server-Xkey");
|
||||
}
|
||||
}
|
||||
89
tests/NATS.Server.Tests/Auth/AuthServiceParityBatch4Tests.cs
Normal file
89
tests/NATS.Server.Tests/Auth/AuthServiceParityBatch4Tests.cs
Normal file
@@ -0,0 +1,89 @@
|
||||
using NATS.NKeys;
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Tests.Auth;
|
||||
|
||||
public class AuthServiceParityBatch4Tests
|
||||
{
|
||||
[Fact]
|
||||
public void Build_assigns_global_account_to_orphan_users()
|
||||
{
|
||||
var service = AuthService.Build(new NatsOptions
|
||||
{
|
||||
Users = [new User { Username = "alice", Password = "secret" }],
|
||||
});
|
||||
|
||||
var result = service.Authenticate(new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { Username = "alice", Password = "secret" },
|
||||
Nonce = [],
|
||||
});
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.AccountName.ShouldBe(Account.GlobalAccountName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_assigns_global_account_to_orphan_nkeys()
|
||||
{
|
||||
using var kp = KeyPair.CreatePair(PrefixByte.User);
|
||||
var pub = kp.GetPublicKey();
|
||||
var nonce = "test-nonce"u8.ToArray();
|
||||
var sig = new byte[64];
|
||||
kp.Sign(nonce, sig);
|
||||
|
||||
var service = AuthService.Build(new NatsOptions
|
||||
{
|
||||
NKeys = [new NKeyUser { Nkey = pub }],
|
||||
});
|
||||
|
||||
var result = service.Authenticate(new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions
|
||||
{
|
||||
Nkey = pub,
|
||||
Sig = Convert.ToBase64String(sig),
|
||||
},
|
||||
Nonce = nonce,
|
||||
});
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.AccountName.ShouldBe(Account.GlobalAccountName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_validates_response_permissions_defaults_and_publish_allow()
|
||||
{
|
||||
var service = AuthService.Build(new NatsOptions
|
||||
{
|
||||
Users =
|
||||
[
|
||||
new User
|
||||
{
|
||||
Username = "alice",
|
||||
Password = "secret",
|
||||
Permissions = new Permissions
|
||||
{
|
||||
Response = new ResponsePermission { MaxMsgs = 0, Expires = TimeSpan.Zero },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
var result = service.Authenticate(new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { Username = "alice", Password = "secret" },
|
||||
Nonce = [],
|
||||
});
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.Permissions.ShouldNotBeNull();
|
||||
result.Permissions.Response.ShouldNotBeNull();
|
||||
result.Permissions.Response.MaxMsgs.ShouldBe(NatsProtocol.DefaultAllowResponseMaxMsgs);
|
||||
result.Permissions.Response.Expires.ShouldBe(NatsProtocol.DefaultAllowResponseExpiration);
|
||||
result.Permissions.Publish.ShouldNotBeNull();
|
||||
result.Permissions.Publish.Allow.ShouldNotBeNull();
|
||||
result.Permissions.Publish.Allow.Count.ShouldBe(0);
|
||||
}
|
||||
}
|
||||
65
tests/NATS.Server.Tests/Auth/TlsMapAuthParityBatch1Tests.cs
Normal file
65
tests/NATS.Server.Tests/Auth/TlsMapAuthParityBatch1Tests.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using NATS.Server.Auth;
|
||||
|
||||
namespace NATS.Server.Tests.Auth;
|
||||
|
||||
public class TlsMapAuthParityBatch1Tests
|
||||
{
|
||||
[Fact]
|
||||
public void GetTlsAuthDcs_extracts_domain_components_from_subject()
|
||||
{
|
||||
using var cert = CreateSelfSignedCert("CN=alice,DC=example,DC=com");
|
||||
|
||||
TlsMapAuthenticator.GetTlsAuthDcs(cert.SubjectName).ShouldBe("DC=example,DC=com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DnsAltNameLabels_and_matches_follow_rfc6125_shape()
|
||||
{
|
||||
var labels = TlsMapAuthenticator.DnsAltNameLabels("*.Example.COM");
|
||||
labels.ShouldBe(["*", "example", "com"]);
|
||||
|
||||
TlsMapAuthenticator.DnsAltNameMatches(labels, [new Uri("nats://node.example.com:6222")]).ShouldBeTrue();
|
||||
TlsMapAuthenticator.DnsAltNameMatches(labels, [new Uri("nats://a.b.example.com:6222")]).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Authenticate_can_match_user_from_email_or_dns_san()
|
||||
{
|
||||
using var cert = CreateSelfSignedCertWithSan("CN=ignored", "ops@example.com", "router.example.com");
|
||||
var auth = new TlsMapAuthenticator([
|
||||
new User { Username = "ops@example.com", Password = "" },
|
||||
new User { Username = "router.example.com", Password = "" },
|
||||
]);
|
||||
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new Protocol.ClientOptions(),
|
||||
Nonce = [],
|
||||
ClientCertificate = cert,
|
||||
};
|
||||
|
||||
var result = auth.Authenticate(ctx);
|
||||
result.ShouldNotBeNull();
|
||||
(result.Identity == "ops@example.com" || result.Identity == "router.example.com").ShouldBeTrue();
|
||||
}
|
||||
|
||||
private static X509Certificate2 CreateSelfSignedCert(string subjectName)
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var req = new CertificateRequest(subjectName, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
return req.CreateSelfSigned(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddYears(1));
|
||||
}
|
||||
|
||||
private static X509Certificate2 CreateSelfSignedCertWithSan(string subjectName, string email, string dns)
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var req = new CertificateRequest(subjectName, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
var sans = new SubjectAlternativeNameBuilder();
|
||||
sans.AddEmailAddress(email);
|
||||
sans.AddDnsName(dns);
|
||||
req.CertificateExtensions.Add(sans.Build());
|
||||
return req.CreateSelfSigned(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddYears(1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Reflection;
|
||||
using NATS.Server.Configuration;
|
||||
|
||||
namespace NATS.Server.Tests.Configuration;
|
||||
|
||||
public class ConfigPedanticParityBatch1Tests
|
||||
{
|
||||
[Fact]
|
||||
public void ParseWithChecks_matches_parse_for_basic_input()
|
||||
{
|
||||
const string config = "port: 4222\nhost: 127.0.0.1\n";
|
||||
|
||||
var regular = NatsConfParser.Parse(config);
|
||||
var withChecks = NatsConfParser.ParseWithChecks(config);
|
||||
|
||||
withChecks["port"].ShouldBe(regular["port"]);
|
||||
withChecks["host"].ShouldBe(regular["host"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseFileWithChecks_and_digest_wrappers_are_available_and_stable()
|
||||
{
|
||||
var path = Path.GetTempFileName();
|
||||
try
|
||||
{
|
||||
File.WriteAllText(path, "port: 4222\n");
|
||||
|
||||
var parsed = NatsConfParser.ParseFileWithChecks(path);
|
||||
parsed["port"].ShouldBe(4222L);
|
||||
|
||||
var (cfg1, d1) = NatsConfParser.ParseFileWithChecksDigest(path);
|
||||
var (cfg2, d2) = NatsConfParser.ParseFileWithDigest(path);
|
||||
var (_, d1Repeat) = NatsConfParser.ParseFileWithChecksDigest(path);
|
||||
|
||||
cfg1["port"].ShouldBe(4222L);
|
||||
cfg2["port"].ShouldBe(4222L);
|
||||
d1.ShouldStartWith("sha256:");
|
||||
d2.ShouldStartWith("sha256:");
|
||||
d1.ShouldBe(d1Repeat);
|
||||
d1.ShouldNotBe(d2);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PedanticToken_accessors_match_expected_values()
|
||||
{
|
||||
var token = new Token(TokenType.Integer, "42", 3, 7);
|
||||
var pedantic = new PedanticToken(token, value: 42L, usedVariable: true, sourceFile: "test.conf");
|
||||
|
||||
pedantic.Value().ShouldBe(42L);
|
||||
pedantic.Line().ShouldBe(3);
|
||||
pedantic.Position().ShouldBe(7);
|
||||
pedantic.IsUsedVariable().ShouldBeTrue();
|
||||
pedantic.SourceFile().ShouldBe("test.conf");
|
||||
pedantic.MarshalJson().ShouldBe("42");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parser_exposes_pedantic_compatibility_hooks()
|
||||
{
|
||||
var parserType = typeof(NatsConfParser);
|
||||
parserType.GetMethod("CleanupUsedEnvVars", BindingFlags.NonPublic | BindingFlags.Static).ShouldNotBeNull();
|
||||
|
||||
var parserStateType = parserType.GetNestedType("ParserState", BindingFlags.NonPublic);
|
||||
parserStateType.ShouldNotBeNull();
|
||||
parserStateType!.GetMethod("PushItemKey", BindingFlags.NonPublic | BindingFlags.Instance).ShouldNotBeNull();
|
||||
parserStateType.GetMethod("PopItemKey", BindingFlags.NonPublic | BindingFlags.Instance).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bcrypt_prefix_values_are_preserved_for_2a_and_2b()
|
||||
{
|
||||
var parsed2a = NatsConfParser.Parse("pwd: $2a$abc\n");
|
||||
var parsed2b = NatsConfParser.Parse("pwd: $2b$abc\n");
|
||||
|
||||
parsed2a["pwd"].ShouldBe("$2a$abc");
|
||||
parsed2b["pwd"].ShouldBe("$2b$abc");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using NATS.Server.Configuration;
|
||||
|
||||
namespace NATS.Server.Tests.Configuration;
|
||||
|
||||
public class ConfigWarningsParityBatch1Tests
|
||||
{
|
||||
[Fact]
|
||||
public void Config_warning_types_expose_message_and_source()
|
||||
{
|
||||
var warning = new ConfigWarningException("warn", "conf:1:2");
|
||||
var unknown = new UnknownConfigFieldWarning("mystery_field", "conf:3:1");
|
||||
|
||||
warning.Message.ShouldBe("warn");
|
||||
warning.SourceLocation.ShouldBe("conf:1:2");
|
||||
unknown.Field.ShouldBe("mystery_field");
|
||||
unknown.SourceLocation.ShouldBe("conf:3:1");
|
||||
unknown.Message.ShouldContain("unknown field");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessConfig_collects_unknown_field_warnings_when_errors_are_present()
|
||||
{
|
||||
var ex = Should.Throw<ConfigProcessorException>(() => ConfigProcessor.ProcessConfig("""
|
||||
max_sub_tokens: 300
|
||||
totally_unknown_field: 1
|
||||
"""));
|
||||
|
||||
ex.Errors.ShouldNotBeEmpty();
|
||||
ex.Warnings.ShouldContain(w => w.Contains("unknown field totally_unknown_field", StringComparison.Ordinal));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
using System.Text.Json;
|
||||
using NATS.Server.Events;
|
||||
|
||||
namespace NATS.Server.Tests.Events;
|
||||
|
||||
public class EventApiAndSubjectsParityBatch2Tests
|
||||
{
|
||||
[Fact]
|
||||
public void EventSubjects_DefineMissingServerRequestSubjects()
|
||||
{
|
||||
EventSubjects.RemoteLatency.ShouldBe("$SYS.SERVER.{0}.ACC.{1}.LATENCY.M2");
|
||||
EventSubjects.UserDirectInfo.ShouldBe("$SYS.REQ.USER.INFO");
|
||||
EventSubjects.UserDirectReq.ShouldBe("$SYS.REQ.USER.{0}.INFO");
|
||||
EventSubjects.AccountNumSubsReq.ShouldBe("$SYS.REQ.ACCOUNT.NSUBS");
|
||||
EventSubjects.AccountSubs.ShouldBe("$SYS._INBOX_.{0}.NSUBS");
|
||||
EventSubjects.ClientKickReq.ShouldBe("$SYS.REQ.SERVER.{0}.KICK");
|
||||
EventSubjects.ClientLdmReq.ShouldBe("$SYS.REQ.SERVER.{0}.LDM");
|
||||
EventSubjects.ServerStatsPingReq.ShouldBe("$SYS.REQ.SERVER.PING.STATSZ");
|
||||
EventSubjects.ServerReloadReq.ShouldBe("$SYS.REQ.SERVER.{0}.RELOAD");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OcspSubjects_MatchGoPatterns()
|
||||
{
|
||||
EventSubjects.OcspPeerReject.ShouldBe("$SYS.SERVER.{0}.OCSP.PEER.CONN.REJECT");
|
||||
EventSubjects.OcspPeerChainlinkInvalid.ShouldBe("$SYS.SERVER.{0}.OCSP.PEER.LINK.INVALID");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OcspPeerRejectEvent_IncludesPeerCertInfo()
|
||||
{
|
||||
var evt = new OcspPeerRejectEventMsg
|
||||
{
|
||||
Id = "id",
|
||||
Kind = "client",
|
||||
Reason = "revoked",
|
||||
Peer = new EventCertInfo
|
||||
{
|
||||
Subject = "CN=client",
|
||||
Issuer = "CN=issuer",
|
||||
Fingerprint = "fingerprint",
|
||||
Raw = "raw",
|
||||
},
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(evt);
|
||||
json.ShouldContain("\"peer\":");
|
||||
json.ShouldContain("\"subject\":\"CN=client\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OcspPeerChainlinkInvalidEvent_SerializesExpectedShape()
|
||||
{
|
||||
var evt = new OcspPeerChainlinkInvalidEventMsg
|
||||
{
|
||||
Id = "id",
|
||||
Link = new EventCertInfo { Subject = "CN=link" },
|
||||
Peer = new EventCertInfo { Subject = "CN=peer" },
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(evt);
|
||||
json.ShouldContain("\"type\":\"io.nats.server.advisory.v1.ocsp_peer_link_invalid\"");
|
||||
json.ShouldContain("\"link\":");
|
||||
json.ShouldContain("\"peer\":");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EventFilterOptions_HasCoreGoFields()
|
||||
{
|
||||
var opts = new EventFilterOptions
|
||||
{
|
||||
Name = "srv-a",
|
||||
Cluster = "cluster-a",
|
||||
Host = "127.0.0.1",
|
||||
Tags = ["a", "b"],
|
||||
Domain = "domain-a",
|
||||
};
|
||||
|
||||
opts.Name.ShouldBe("srv-a");
|
||||
opts.Cluster.ShouldBe("cluster-a");
|
||||
opts.Host.ShouldBe("127.0.0.1");
|
||||
opts.Tags.ShouldBe(["a", "b"]);
|
||||
opts.Domain.ShouldBe("domain-a");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OptionRequestTypes_IncludeBaseFilterFields()
|
||||
{
|
||||
new StatszEventOptions { Name = "n" }.Name.ShouldBe("n");
|
||||
new ConnzEventOptions { Cluster = "c" }.Cluster.ShouldBe("c");
|
||||
new RoutezEventOptions { Host = "h" }.Host.ShouldBe("h");
|
||||
new HealthzEventOptions { Domain = "d" }.Domain.ShouldBe("d");
|
||||
new JszEventOptions { Tags = ["t"] }.Tags.ShouldBe(["t"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ServerApiResponses_ExposeDataAndError()
|
||||
{
|
||||
var response = new ServerAPIResponse
|
||||
{
|
||||
Server = new EventServerInfo { Id = "S1" },
|
||||
Data = new { ok = true },
|
||||
Error = new ServerAPIError { Code = 500, Description = "err" },
|
||||
};
|
||||
|
||||
response.Server.Id.ShouldBe("S1");
|
||||
response.Error?.Code.ShouldBe(500);
|
||||
response.Error?.Description.ShouldBe("err");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TypedServerApiWrappers_CarryResponsePayload()
|
||||
{
|
||||
new ServerAPIConnzResponse { Data = new object() }.Data.ShouldNotBeNull();
|
||||
new ServerAPIRoutezResponse { Data = new object() }.Data.ShouldNotBeNull();
|
||||
new ServerAPIGatewayzResponse { Data = new object() }.Data.ShouldNotBeNull();
|
||||
new ServerAPIJszResponse { Data = new object() }.Data.ShouldNotBeNull();
|
||||
new ServerAPIHealthzResponse { Data = new object() }.Data.ShouldNotBeNull();
|
||||
new ServerAPIVarzResponse { Data = new object() }.Data.ShouldNotBeNull();
|
||||
new ServerAPISubszResponse { Data = new object() }.Data.ShouldNotBeNull();
|
||||
new ServerAPILeafzResponse { Data = new object() }.Data.ShouldNotBeNull();
|
||||
new ServerAPIAccountzResponse { Data = new object() }.Data.ShouldNotBeNull();
|
||||
new ServerAPIExpvarzResponse { Data = new object() }.Data.ShouldNotBeNull();
|
||||
new ServerAPIpqueueszResponse { Data = new object() }.Data.ShouldNotBeNull();
|
||||
new ServerAPIRaftzResponse { Data = new object() }.Data.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequestPayloadTypes_KickAndLdm()
|
||||
{
|
||||
var kick = new KickClientReq { ClientId = 22 };
|
||||
var ldm = new LDMClientReq { ClientId = 33 };
|
||||
|
||||
kick.ClientId.ShouldBe(22UL);
|
||||
ldm.ClientId.ShouldBe(33UL);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserInfo_IncludesExpectedIdentityFields()
|
||||
{
|
||||
var info = new UserInfo
|
||||
{
|
||||
User = "alice",
|
||||
Account = "A",
|
||||
Permissions = "pubsub",
|
||||
};
|
||||
|
||||
info.User.ShouldBe("alice");
|
||||
info.Account.ShouldBe("A");
|
||||
info.Permissions.ShouldBe("pubsub");
|
||||
}
|
||||
}
|
||||
@@ -168,4 +168,31 @@ public class EventCompressionTests : IDisposable
|
||||
EventCompressor.TotalUncompressed.ShouldBe(0L);
|
||||
EventCompressor.BytesSaved.ShouldBe(0L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAcceptEncoding_ParsesSnappyAndGzip()
|
||||
{
|
||||
EventCompressor.GetAcceptEncoding("gzip, snappy").ShouldBe(EventCompressionType.Snappy);
|
||||
EventCompressor.GetAcceptEncoding("gzip").ShouldBe(EventCompressionType.Gzip);
|
||||
EventCompressor.GetAcceptEncoding("br").ShouldBe(EventCompressionType.Unsupported);
|
||||
EventCompressor.GetAcceptEncoding(null).ShouldBe(EventCompressionType.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompressionHeaderConstants_MatchGo()
|
||||
{
|
||||
EventCompressor.AcceptEncodingHeader.ShouldBe("Accept-Encoding");
|
||||
EventCompressor.ContentEncodingHeader.ShouldBe("Content-Encoding");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompressAndDecompress_Gzip_RoundTrip_MatchesOriginal()
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes("""{"server":"s1","data":"gzip-payload"}""");
|
||||
|
||||
var compressed = EventCompressor.Compress(payload, EventCompressionType.Gzip);
|
||||
var restored = EventCompressor.Decompress(compressed, EventCompressionType.Gzip);
|
||||
|
||||
restored.ShouldBe(payload);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using System.Text.Json;
|
||||
using NATS.Server.Events;
|
||||
|
||||
namespace NATS.Server.Tests.Events;
|
||||
|
||||
public class EventServerInfoCapabilityParityBatch1Tests
|
||||
{
|
||||
[Fact]
|
||||
public void ServerCapability_flags_match_expected_values()
|
||||
{
|
||||
((ulong)ServerCapability.JetStreamEnabled).ShouldBe(1UL << 0);
|
||||
((ulong)ServerCapability.BinaryStreamSnapshot).ShouldBe(1UL << 1);
|
||||
((ulong)ServerCapability.AccountNRG).ShouldBe(1UL << 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EventServerInfo_capability_methods_set_and_read_flags()
|
||||
{
|
||||
var info = new EventServerInfo();
|
||||
|
||||
info.SetJetStreamEnabled();
|
||||
info.SetBinaryStreamSnapshot();
|
||||
info.SetAccountNRG();
|
||||
|
||||
info.JetStream.ShouldBeTrue();
|
||||
info.JetStreamEnabled().ShouldBeTrue();
|
||||
info.BinaryStreamSnapshot().ShouldBeTrue();
|
||||
info.AccountNRG().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ServerID_serializes_with_name_host_id_fields()
|
||||
{
|
||||
var payload = new ServerID
|
||||
{
|
||||
Name = "srv-a",
|
||||
Host = "127.0.0.1",
|
||||
Id = "N1",
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(payload);
|
||||
json.ShouldContain("\"name\":\"srv-a\"");
|
||||
json.ShouldContain("\"host\":\"127.0.0.1\"");
|
||||
json.ShouldContain("\"id\":\"N1\"");
|
||||
}
|
||||
}
|
||||
@@ -129,7 +129,7 @@ public class RemoteServerEventTests
|
||||
string.Format(EventSubjects.RemoteServerShutdown, serverId)
|
||||
.ShouldBe($"$SYS.SERVER.{serverId}.REMOTE.SHUTDOWN");
|
||||
string.Format(EventSubjects.LeafNodeConnected, serverId)
|
||||
.ShouldBe($"$SYS.SERVER.{serverId}.LEAFNODE.CONNECT");
|
||||
.ShouldBe($"$SYS.ACCOUNT.{serverId}.LEAFNODE.CONNECT");
|
||||
}
|
||||
|
||||
// --- JSON serialization ---
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server.Configuration;
|
||||
|
||||
namespace NATS.Server.Tests.Gateways;
|
||||
|
||||
public class GatewayConnectionDirectionParityBatch2Tests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Gateway_manager_tracks_inbound_and_outbound_connection_sets()
|
||||
{
|
||||
var a = await StartServerAsync(MakeGatewayOptions("GW-A"));
|
||||
var b = await StartServerAsync(MakeGatewayOptions("GW-B", a.Server.GatewayListen));
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForCondition(() =>
|
||||
a.Server.NumInboundGateways() == 1 &&
|
||||
b.Server.NumOutboundGateways() == 1,
|
||||
10000);
|
||||
|
||||
a.Server.NumInboundGateways().ShouldBe(1);
|
||||
a.Server.NumOutboundGateways().ShouldBe(0);
|
||||
b.Server.NumOutboundGateways().ShouldBe(1);
|
||||
b.Server.NumInboundGateways().ShouldBe(0);
|
||||
|
||||
var aManager = a.Server.GatewayManager;
|
||||
var bManager = b.Server.GatewayManager;
|
||||
aManager.ShouldNotBeNull();
|
||||
bManager.ShouldNotBeNull();
|
||||
|
||||
aManager!.HasInbound(b.Server.ServerId).ShouldBeTrue();
|
||||
bManager!.HasInbound(a.Server.ServerId).ShouldBeFalse();
|
||||
|
||||
bManager.GetOutboundGatewayConnection(a.Server.ServerId).ShouldNotBeNull();
|
||||
bManager.GetOutboundGatewayConnection("does-not-exist").ShouldBeNull();
|
||||
|
||||
aManager.GetInboundGatewayConnections().Count.ShouldBe(1);
|
||||
aManager.GetOutboundGatewayConnections().Count.ShouldBe(0);
|
||||
bManager.GetOutboundGatewayConnections().Count.ShouldBe(1);
|
||||
bManager.GetInboundGatewayConnections().Count.ShouldBe(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisposeServers(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
private static NatsOptions MakeGatewayOptions(string gatewayName, string? remote = null)
|
||||
{
|
||||
return new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Gateway = new GatewayOptions
|
||||
{
|
||||
Name = gatewayName,
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Remotes = remote is null ? [] : [remote],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<(NatsServer Server, CancellationTokenSource Cts)> StartServerAsync(NatsOptions opts)
|
||||
{
|
||||
var server = new NatsServer(opts, NullLoggerFactory.Instance);
|
||||
var cts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
return (server, cts);
|
||||
}
|
||||
|
||||
private static async Task DisposeServers(params (NatsServer Server, CancellationTokenSource Cts)[] servers)
|
||||
{
|
||||
foreach (var (server, cts) in servers)
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
cts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WaitForCondition(Func<bool> predicate, int timeoutMs)
|
||||
{
|
||||
using var cts = new CancellationTokenSource(timeoutMs);
|
||||
while (!cts.IsCancellationRequested)
|
||||
{
|
||||
if (predicate())
|
||||
return;
|
||||
await Task.Yield();
|
||||
}
|
||||
|
||||
throw new TimeoutException("Condition not met.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using NATS.Server.Configuration;
|
||||
|
||||
namespace NATS.Server.Tests.Gateways;
|
||||
|
||||
public class GatewayRemoteConfigParityBatch3Tests
|
||||
{
|
||||
[Fact]
|
||||
public void RemoteGatewayOptions_tracks_connection_attempts_and_implicit_flag()
|
||||
{
|
||||
var cfg = new RemoteGatewayOptions { Name = "GW-B", Implicit = true };
|
||||
|
||||
cfg.IsImplicit().ShouldBeTrue();
|
||||
cfg.GetConnAttempts().ShouldBe(0);
|
||||
cfg.BumpConnAttempts().ShouldBe(1);
|
||||
cfg.BumpConnAttempts().ShouldBe(2);
|
||||
cfg.GetConnAttempts().ShouldBe(2);
|
||||
cfg.ResetConnAttempts();
|
||||
cfg.GetConnAttempts().ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoteGatewayOptions_add_and_update_urls_normalize_and_deduplicate()
|
||||
{
|
||||
var cfg = new RemoteGatewayOptions();
|
||||
cfg.AddUrls(["127.0.0.1:7222", "nats://127.0.0.1:7222", "nats://127.0.0.1:7223"]);
|
||||
|
||||
cfg.Urls.Count.ShouldBe(2);
|
||||
cfg.Urls.ShouldContain("nats://127.0.0.1:7222");
|
||||
cfg.Urls.ShouldContain("nats://127.0.0.1:7223");
|
||||
|
||||
cfg.UpdateUrls(
|
||||
configuredUrls: ["127.0.0.1:7333"],
|
||||
discoveredUrls: ["nats://127.0.0.1:7334", "127.0.0.1:7333"]);
|
||||
|
||||
cfg.Urls.Count.ShouldBe(2);
|
||||
cfg.Urls.ShouldContain("nats://127.0.0.1:7333");
|
||||
cfg.Urls.ShouldContain("nats://127.0.0.1:7334");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoteGatewayOptions_save_tls_hostname_and_get_urls_helpers()
|
||||
{
|
||||
var cfg = new RemoteGatewayOptions
|
||||
{
|
||||
Urls = ["127.0.0.1:7444", "nats://localhost:7445"],
|
||||
};
|
||||
|
||||
cfg.SaveTlsHostname("nats://gw.example.net:7522");
|
||||
cfg.TlsName.ShouldBe("gw.example.net");
|
||||
|
||||
var urlStrings = cfg.GetUrlsAsStrings();
|
||||
urlStrings.Count.ShouldBe(2);
|
||||
urlStrings.ShouldContain("nats://127.0.0.1:7444");
|
||||
urlStrings.ShouldContain("nats://localhost:7445");
|
||||
|
||||
var urls = cfg.GetUrls();
|
||||
urls.Count.ShouldBe(2);
|
||||
urls.ShouldContain(u => u.Authority == "127.0.0.1:7444");
|
||||
urls.ShouldContain(u => u.Authority == "localhost:7445");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using NATS.Server.Configuration;
|
||||
using NATS.Server.Gateways;
|
||||
|
||||
namespace NATS.Server.Tests.Gateways;
|
||||
|
||||
public class GatewayReplyAndConfigParityBatch1Tests
|
||||
{
|
||||
[Fact]
|
||||
public void HasGatewayReplyPrefix_accepts_new_and_old_prefixes()
|
||||
{
|
||||
ReplyMapper.HasGatewayReplyPrefix("_GR_.clusterA.reply").ShouldBeTrue();
|
||||
ReplyMapper.HasGatewayReplyPrefix("$GR.clusterA.reply").ShouldBeTrue();
|
||||
ReplyMapper.HasGatewayReplyPrefix("_INBOX.reply").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsGatewayRoutedSubject_reports_old_prefix_flag()
|
||||
{
|
||||
ReplyMapper.IsGatewayRoutedSubject("_GR_.C1.r", out var newPrefixOldFlag).ShouldBeTrue();
|
||||
newPrefixOldFlag.ShouldBeFalse();
|
||||
|
||||
ReplyMapper.IsGatewayRoutedSubject("$GR.C1.r", out var oldPrefixOldFlag).ShouldBeTrue();
|
||||
oldPrefixOldFlag.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryRestoreGatewayReply_handles_old_prefix_format()
|
||||
{
|
||||
ReplyMapper.TryRestoreGatewayReply("$GR.clusterA.reply.one", out var restored).ShouldBeTrue();
|
||||
restored.ShouldBe("reply.one");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GatewayHash_helpers_are_deterministic_and_expected_length()
|
||||
{
|
||||
var hash1 = ReplyMapper.ComputeGatewayHash("east");
|
||||
var hash2 = ReplyMapper.ComputeGatewayHash("east");
|
||||
var oldHash1 = ReplyMapper.ComputeOldGatewayHash("east");
|
||||
var oldHash2 = ReplyMapper.ComputeOldGatewayHash("east");
|
||||
|
||||
hash1.ShouldBe(hash2);
|
||||
oldHash1.ShouldBe(oldHash2);
|
||||
hash1.Length.ShouldBe(ReplyMapper.GatewayHashLen);
|
||||
oldHash1.Length.ShouldBe(ReplyMapper.OldGatewayHashLen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Legacy_prefixed_reply_extracts_cluster_and_not_hash()
|
||||
{
|
||||
ReplyMapper.TryExtractClusterId("$GR.clusterB.inbox.reply", out var cluster).ShouldBeTrue();
|
||||
cluster.ShouldBe("clusterB");
|
||||
ReplyMapper.TryExtractHash("$GR.clusterB.inbox.reply", out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoteGatewayOptions_clone_deep_copies_url_list()
|
||||
{
|
||||
var original = new RemoteGatewayOptions
|
||||
{
|
||||
Name = "gw-west",
|
||||
Urls = ["nats://127.0.0.1:7522", "nats://127.0.0.1:7523"],
|
||||
};
|
||||
|
||||
var clone = original.Clone();
|
||||
clone.ShouldNotBeSameAs(original);
|
||||
clone.Name.ShouldBe(original.Name);
|
||||
clone.Urls.ShouldBe(original.Urls);
|
||||
|
||||
clone.Urls.Add("nats://127.0.0.1:7524");
|
||||
original.Urls.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateGatewayOptions_checks_required_fields()
|
||||
{
|
||||
GatewayManager.ValidateGatewayOptions(new GatewayOptions
|
||||
{
|
||||
Name = "gw",
|
||||
Host = "127.0.0.1",
|
||||
Port = 7222,
|
||||
Remotes = ["127.0.0.1:8222"],
|
||||
}, out var error).ShouldBeTrue();
|
||||
error.ShouldBeNull();
|
||||
|
||||
GatewayManager.ValidateGatewayOptions(new GatewayOptions { Port = 7222 }, out error).ShouldBeFalse();
|
||||
error.ShouldNotBeNull();
|
||||
error.ShouldContain("name");
|
||||
|
||||
GatewayManager.ValidateGatewayOptions(new GatewayOptions { Name = "gw", Port = -1 }, out error).ShouldBeFalse();
|
||||
error.ShouldNotBeNull();
|
||||
error.ShouldContain("0-65535");
|
||||
|
||||
GatewayManager.ValidateGatewayOptions(new GatewayOptions { Name = "gw", Port = 7222, Remotes = [""] }, out error).ShouldBeFalse();
|
||||
error.ShouldNotBeNull();
|
||||
error.ShouldContain("cannot be empty");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gateway_tls_warning_constant_is_present()
|
||||
{
|
||||
GatewayManager.GatewayTlsInsecureWarning.ShouldNotBeNullOrWhiteSpace();
|
||||
GatewayManager.GatewayTlsInsecureWarning.ShouldContain("TLS");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server.Configuration;
|
||||
|
||||
namespace NATS.Server.Tests.Gateways;
|
||||
|
||||
public class GatewayServerAccessorParityBatch4Tests
|
||||
{
|
||||
[Fact]
|
||||
public void Gateway_address_url_and_name_accessors_reflect_gateway_options()
|
||||
{
|
||||
using var server = new NatsServer(
|
||||
new NatsOptions
|
||||
{
|
||||
Gateway = new GatewayOptions
|
||||
{
|
||||
Name = "gw-a",
|
||||
Host = "127.0.0.1",
|
||||
Port = 7222,
|
||||
},
|
||||
},
|
||||
NullLoggerFactory.Instance);
|
||||
|
||||
server.GatewayAddr().ShouldBe("127.0.0.1:7222");
|
||||
server.GetGatewayURL().ShouldBe("127.0.0.1:7222");
|
||||
server.GetGatewayName().ShouldBe("gw-a");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gateway_accessors_return_null_when_gateway_is_not_configured()
|
||||
{
|
||||
using var server = new NatsServer(new NatsOptions(), NullLoggerFactory.Instance);
|
||||
|
||||
server.GatewayAddr().ShouldBeNull();
|
||||
server.GetGatewayURL().ShouldBeNull();
|
||||
server.GetGatewayName().ShouldBeNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using System.Text;
|
||||
using NATS.Server.Internal.Avl;
|
||||
using NATS.Server.Internal.Gsl;
|
||||
using NATS.Server.Internal.SubjectTree;
|
||||
using NATS.Server.Internal.SysMem;
|
||||
using NATS.Server.Internal.TimeHashWheel;
|
||||
|
||||
namespace NATS.Server.Tests.Internal;
|
||||
|
||||
public class InternalDsParityBatch2Tests
|
||||
{
|
||||
[Fact]
|
||||
public void SubjectTreeHelper_IntersectGSL_matches_interested_subjects_once()
|
||||
{
|
||||
var tree = new SubjectTree<int>();
|
||||
tree.Insert("foo.bar"u8.ToArray(), 1);
|
||||
tree.Insert("foo.baz"u8.ToArray(), 2);
|
||||
tree.Insert("other.subject"u8.ToArray(), 3);
|
||||
|
||||
var sublist = new GenericSubjectList<int>();
|
||||
sublist.Insert("foo.*", 1);
|
||||
sublist.Insert("foo.bar", 2); // overlap should not duplicate callback for same subject
|
||||
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
SubjectTreeHelper.IntersectGSL(tree, sublist, (subject, _) =>
|
||||
{
|
||||
seen.Add(Encoding.UTF8.GetString(subject));
|
||||
});
|
||||
|
||||
seen.Count.ShouldBe(2);
|
||||
seen.ShouldContain("foo.bar");
|
||||
seen.ShouldContain("foo.baz");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubjectTree_Dump_outputs_node_and_leaf_structure()
|
||||
{
|
||||
var tree = new SubjectTree<int>();
|
||||
tree.Insert("foo.bar"u8.ToArray(), 1);
|
||||
tree.Insert("foo.baz"u8.ToArray(), 2);
|
||||
|
||||
using var sw = new StringWriter();
|
||||
tree.Dump(sw);
|
||||
var dump = sw.ToString();
|
||||
|
||||
dump.ShouldContain("NODE");
|
||||
dump.ShouldContain("LEAF");
|
||||
dump.ShouldContain("Prefix:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SequenceSet_Encode_supports_destination_buffer_reuse()
|
||||
{
|
||||
var set = new SequenceSet();
|
||||
set.Insert(1);
|
||||
set.Insert(65);
|
||||
set.Insert(1024);
|
||||
|
||||
var buffer = new byte[set.EncodeLength() + 32];
|
||||
var written = set.Encode(buffer);
|
||||
written.ShouldBe(set.EncodeLength());
|
||||
|
||||
var (decoded, bytesRead) = SequenceSet.Decode(buffer.AsSpan(0, written));
|
||||
bytesRead.ShouldBe(written);
|
||||
decoded.Exists(1).ShouldBeTrue();
|
||||
decoded.Exists(65).ShouldBeTrue();
|
||||
decoded.Exists(1024).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HashWheelEntry_struct_exposes_sequence_and_expiration()
|
||||
{
|
||||
var entry = new HashWheel.HashWheelEntry(42, 99);
|
||||
entry.Sequence.ShouldBe((ulong)42);
|
||||
entry.Expires.ShouldBe(99);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SystemMemory_returns_positive_memory_value()
|
||||
{
|
||||
SystemMemory.Memory().ShouldBeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SimpleSubjectList_works_with_empty_marker_values()
|
||||
{
|
||||
var list = new SimpleSubjectList();
|
||||
list.Insert("foo.bar", new SimpleSublistValue());
|
||||
list.HasInterest("foo.bar").ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server.Monitoring;
|
||||
|
||||
namespace NATS.Server.Tests.Internal;
|
||||
|
||||
public class InternalDsPeriodicSamplerParityTests
|
||||
{
|
||||
[Fact]
|
||||
[SlopwatchSuppress("SW004", "Test must observe a real 1-second CPU sampling timer tick; wall-clock elapsed time is the observable under test")]
|
||||
public async Task VarzHandler_uses_periodic_background_cpu_sampler()
|
||||
{
|
||||
var options = new NatsOptions { Host = "127.0.0.1", Port = 0 };
|
||||
var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
using var cts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
using var handler = new VarzHandler(server, options, NullLoggerFactory.Instance);
|
||||
var field = typeof(VarzHandler).GetField("_lastCpuSampleTime", BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
field.ShouldNotBeNull();
|
||||
|
||||
var before = (DateTime)field!.GetValue(handler)!;
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(1200));
|
||||
var after = (DateTime)field.GetValue(handler)!;
|
||||
|
||||
after.ShouldBeGreaterThan(before);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using NATS.Server.JetStream.Api;
|
||||
using NATS.Server.JetStream.Models;
|
||||
using NATS.Server.JetStream.Validation;
|
||||
using NATS.Server.JetStream;
|
||||
|
||||
namespace NATS.Server.Tests.JetStream.Api;
|
||||
|
||||
public class JetStreamApiLimitsParityBatch1Tests
|
||||
{
|
||||
[Fact]
|
||||
public void Constants_match_go_reference_values()
|
||||
{
|
||||
JetStreamApiLimits.JSMaxDescriptionLen.ShouldBe(4_096);
|
||||
JetStreamApiLimits.JSMaxMetadataLen.ShouldBe(128 * 1024);
|
||||
JetStreamApiLimits.JSMaxNameLen.ShouldBe(255);
|
||||
JetStreamApiLimits.JSDefaultRequestQueueLimit.ShouldBe(10_000);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, false)]
|
||||
[InlineData("", false)]
|
||||
[InlineData(" ", false)]
|
||||
[InlineData("ORDERS", true)]
|
||||
[InlineData("ORD ERS", false)]
|
||||
[InlineData("ORDERS.*", false)]
|
||||
[InlineData("ORDERS.>", false)]
|
||||
public void IsValidName_enforces_expected_rules(string? name, bool expected)
|
||||
{
|
||||
JetStreamConfigValidator.IsValidName(name).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stream_create_rejects_name_over_max_length()
|
||||
{
|
||||
var manager = new StreamManager();
|
||||
var response = manager.CreateOrUpdate(new StreamConfig
|
||||
{
|
||||
Name = new string('S', JetStreamApiLimits.JSMaxNameLen + 1),
|
||||
Subjects = ["a"],
|
||||
});
|
||||
|
||||
response.Error.ShouldNotBeNull();
|
||||
response.Error!.Description.ShouldBe("invalid stream name");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stream_create_rejects_description_over_max_bytes()
|
||||
{
|
||||
var manager = new StreamManager();
|
||||
var response = manager.CreateOrUpdate(new StreamConfig
|
||||
{
|
||||
Name = "LIMITDESC",
|
||||
Subjects = ["a"],
|
||||
Description = new string('d', JetStreamApiLimits.JSMaxDescriptionLen + 1),
|
||||
});
|
||||
|
||||
response.Error.ShouldNotBeNull();
|
||||
response.Error!.Description.ShouldBe("stream description is too long");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stream_create_rejects_metadata_over_max_bytes()
|
||||
{
|
||||
var manager = new StreamManager();
|
||||
var response = manager.CreateOrUpdate(new StreamConfig
|
||||
{
|
||||
Name = "LIMITMETA",
|
||||
Subjects = ["a"],
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["k"] = new string('m', JetStreamApiLimits.JSMaxMetadataLen),
|
||||
},
|
||||
});
|
||||
|
||||
response.Error.ShouldNotBeNull();
|
||||
response.Error!.Description.ShouldBe("stream metadata exceeds maximum size");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Consumer_create_rejects_durable_name_over_max_length()
|
||||
{
|
||||
var manager = new ConsumerManager();
|
||||
var response = manager.CreateOrUpdate("S", new ConsumerConfig
|
||||
{
|
||||
DurableName = new string('C', JetStreamApiLimits.JSMaxNameLen + 1),
|
||||
});
|
||||
|
||||
response.Error.ShouldNotBeNull();
|
||||
response.Error!.Description.ShouldBe("invalid durable name");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Consumer_create_rejects_metadata_over_max_bytes()
|
||||
{
|
||||
var manager = new ConsumerManager();
|
||||
var response = manager.CreateOrUpdate("S", new ConsumerConfig
|
||||
{
|
||||
DurableName = "C1",
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["k"] = new string('m', JetStreamApiLimits.JSMaxMetadataLen),
|
||||
},
|
||||
});
|
||||
|
||||
response.Error.ShouldNotBeNull();
|
||||
response.Error!.Description.ShouldBe("consumer metadata exceeds maximum size");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using NATS.Server.Configuration;
|
||||
using NATS.Server.JetStream;
|
||||
|
||||
namespace NATS.Server.Tests.JetStream;
|
||||
|
||||
public class JetStreamConfigModelParityBatch3Tests
|
||||
{
|
||||
[Fact]
|
||||
public void JetStreamOptions_exposes_extended_go_config_fields()
|
||||
{
|
||||
var opts = new JetStreamOptions
|
||||
{
|
||||
SyncInterval = TimeSpan.FromSeconds(2),
|
||||
SyncAlways = true,
|
||||
CompressOk = true,
|
||||
UniqueTag = "az",
|
||||
Strict = true,
|
||||
MaxAckPending = 123,
|
||||
MemoryMaxStreamBytes = 1111,
|
||||
StoreMaxStreamBytes = 2222,
|
||||
MaxBytesRequired = true,
|
||||
};
|
||||
|
||||
opts.SyncInterval.ShouldBe(TimeSpan.FromSeconds(2));
|
||||
opts.SyncAlways.ShouldBeTrue();
|
||||
opts.CompressOk.ShouldBeTrue();
|
||||
opts.UniqueTag.ShouldBe("az");
|
||||
opts.Strict.ShouldBeTrue();
|
||||
opts.MaxAckPending.ShouldBe(123);
|
||||
opts.MemoryMaxStreamBytes.ShouldBe(1111);
|
||||
opts.StoreMaxStreamBytes.ShouldBe(2222);
|
||||
opts.MaxBytesRequired.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigProcessor_parses_extended_jetstream_fields()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfig("""
|
||||
jetstream {
|
||||
store_dir: '/tmp/js'
|
||||
max_mem_store: 1024
|
||||
max_file_store: 2048
|
||||
domain: 'D'
|
||||
sync_interval: '2s'
|
||||
sync_always: true
|
||||
compress_ok: true
|
||||
unique_tag: 'az'
|
||||
strict: true
|
||||
max_ack_pending: 42
|
||||
memory_max_stream_bytes: 10000
|
||||
store_max_stream_bytes: 20000
|
||||
max_bytes_required: true
|
||||
}
|
||||
""");
|
||||
|
||||
opts.JetStream.ShouldNotBeNull();
|
||||
var js = opts.JetStream!;
|
||||
js.StoreDir.ShouldBe("/tmp/js");
|
||||
js.MaxMemoryStore.ShouldBe(1024);
|
||||
js.MaxFileStore.ShouldBe(2048);
|
||||
js.Domain.ShouldBe("D");
|
||||
js.SyncInterval.ShouldBe(TimeSpan.FromSeconds(2));
|
||||
js.SyncAlways.ShouldBeTrue();
|
||||
js.CompressOk.ShouldBeTrue();
|
||||
js.UniqueTag.ShouldBe("az");
|
||||
js.Strict.ShouldBeTrue();
|
||||
js.MaxAckPending.ShouldBe(42);
|
||||
js.MemoryMaxStreamBytes.ShouldBe(10000);
|
||||
js.StoreMaxStreamBytes.ShouldBe(20000);
|
||||
js.MaxBytesRequired.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JetStream_struct_models_cover_stats_limits_and_tiers()
|
||||
{
|
||||
var api = new JetStreamApiStats
|
||||
{
|
||||
Total = 10,
|
||||
Errors = 2,
|
||||
Inflight = 1,
|
||||
};
|
||||
|
||||
var tier = new JetStreamTier
|
||||
{
|
||||
Name = "R3",
|
||||
Memory = 1000,
|
||||
Store = 2000,
|
||||
Streams = 3,
|
||||
Consumers = 5,
|
||||
};
|
||||
|
||||
var limits = new JetStreamAccountLimits
|
||||
{
|
||||
MaxMemory = 10_000,
|
||||
MaxStore = 20_000,
|
||||
MaxStreams = 7,
|
||||
MaxConsumers = 9,
|
||||
MaxAckPending = 25,
|
||||
MemoryMaxStreamBytes = 1_000,
|
||||
StoreMaxStreamBytes = 2_000,
|
||||
MaxBytesRequired = true,
|
||||
Tiers = new Dictionary<string, JetStreamTier>
|
||||
{
|
||||
["R3"] = tier,
|
||||
},
|
||||
};
|
||||
|
||||
var stats = new JetStreamStats
|
||||
{
|
||||
Memory = 123,
|
||||
Store = 456,
|
||||
ReservedMemory = 11,
|
||||
ReservedStore = 22,
|
||||
Accounts = 2,
|
||||
HaAssets = 4,
|
||||
Api = api,
|
||||
};
|
||||
|
||||
limits.Tiers["R3"].Name.ShouldBe("R3");
|
||||
limits.MaxAckPending.ShouldBe(25);
|
||||
limits.MaxBytesRequired.ShouldBeTrue();
|
||||
|
||||
stats.Memory.ShouldBe(123);
|
||||
stats.Store.ShouldBe(456);
|
||||
stats.Api.Total.ShouldBe(10UL);
|
||||
stats.Api.Errors.ShouldBe(2UL);
|
||||
stats.Api.Inflight.ShouldBe(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server.Configuration;
|
||||
|
||||
namespace NATS.Server.Tests.JetStream;
|
||||
|
||||
public class JetStreamServerConfigParityBatch2Tests
|
||||
{
|
||||
[Fact]
|
||||
public void JetStream_constants_match_go_default_values()
|
||||
{
|
||||
JetStreamOptions.JetStreamStoreDir.ShouldBe("jetstream");
|
||||
JetStreamOptions.JetStreamMaxStoreDefault.ShouldBe(1L << 40);
|
||||
JetStreamOptions.JetStreamMaxMemDefault.ShouldBe(256L * 1024 * 1024);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Server_exposes_jetstream_enabled_config_and_store_dir()
|
||||
{
|
||||
var options = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
JetStream = new JetStreamOptions
|
||||
{
|
||||
StoreDir = Path.Combine(Path.GetTempPath(), "js-" + Guid.NewGuid().ToString("N")),
|
||||
MaxMemoryStore = 10_000,
|
||||
MaxFileStore = 20_000,
|
||||
MaxStreams = 7,
|
||||
MaxConsumers = 11,
|
||||
Domain = "D1",
|
||||
},
|
||||
};
|
||||
|
||||
var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
using var cts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
server.JetStreamEnabled().ShouldBeTrue();
|
||||
server.StoreDir().ShouldBe(options.JetStream.StoreDir);
|
||||
|
||||
var cfg = server.JetStreamConfig();
|
||||
cfg.ShouldNotBeNull();
|
||||
cfg!.StoreDir.ShouldBe(options.JetStream.StoreDir);
|
||||
cfg.MaxMemoryStore.ShouldBe(options.JetStream.MaxMemoryStore);
|
||||
cfg.MaxFileStore.ShouldBe(options.JetStream.MaxFileStore);
|
||||
cfg.MaxStreams.ShouldBe(options.JetStream.MaxStreams);
|
||||
cfg.MaxConsumers.ShouldBe(options.JetStream.MaxConsumers);
|
||||
cfg.Domain.ShouldBe(options.JetStream.Domain);
|
||||
|
||||
cfg.MaxStreams = 99;
|
||||
server.JetStreamConfig()!.MaxStreams.ShouldBe(options.JetStream.MaxStreams);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
if (Directory.Exists(options.JetStream.StoreDir))
|
||||
Directory.Delete(options.JetStream.StoreDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Server_returns_empty_or_null_jetstream_config_when_disabled()
|
||||
{
|
||||
var server = new NatsServer(new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
}, NullLoggerFactory.Instance);
|
||||
|
||||
server.JetStreamEnabled().ShouldBeFalse();
|
||||
server.JetStreamConfig().ShouldBeNull();
|
||||
server.StoreDir().ShouldBe(string.Empty);
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ public class JetStreamMonitoringParityTests
|
||||
jsz.Consumers.ShouldBeGreaterThanOrEqualTo(1);
|
||||
jsz.ApiTotal.ShouldBeGreaterThanOrEqualTo((ulong)0);
|
||||
|
||||
var varz = await new VarzHandler(server, options).HandleVarzAsync();
|
||||
var varz = await new VarzHandler(server, options, NullLoggerFactory.Instance).HandleVarzAsync();
|
||||
varz.JetStream.Stats.Api.Total.ShouldBeGreaterThanOrEqualTo((ulong)0);
|
||||
|
||||
await cts.CancelAsync();
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server.Configuration;
|
||||
using NATS.Server.LeafNodes;
|
||||
|
||||
namespace NATS.Server.Tests.LeafNodes;
|
||||
|
||||
public class LeafConnectionAndRemoteConfigParityBatch1Tests
|
||||
{
|
||||
[Fact]
|
||||
public async Task LeafConnection_role_helpers_reflect_connection_flags()
|
||||
{
|
||||
await using var connection = CreateConnection();
|
||||
|
||||
connection.IsSolicitedLeafNode().ShouldBeFalse();
|
||||
connection.IsSpokeLeafNode().ShouldBeFalse();
|
||||
connection.IsHubLeafNode().ShouldBeTrue();
|
||||
connection.IsIsolatedLeafNode().ShouldBeFalse();
|
||||
|
||||
connection.IsSolicited = true;
|
||||
connection.IsSpoke = true;
|
||||
connection.Isolated = true;
|
||||
|
||||
connection.IsSolicitedLeafNode().ShouldBeTrue();
|
||||
connection.IsSpokeLeafNode().ShouldBeTrue();
|
||||
connection.IsHubLeafNode().ShouldBeFalse();
|
||||
connection.IsIsolatedLeafNode().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoteLeafOptions_pick_next_url_round_robins()
|
||||
{
|
||||
var remote = new RemoteLeafOptions
|
||||
{
|
||||
Urls =
|
||||
[
|
||||
"nats://127.0.0.1:7422",
|
||||
"nats://127.0.0.1:7423",
|
||||
"nats://127.0.0.1:7424",
|
||||
],
|
||||
};
|
||||
|
||||
remote.PickNextUrl().ShouldBe("nats://127.0.0.1:7422");
|
||||
remote.GetCurrentUrl().ShouldBe("nats://127.0.0.1:7422");
|
||||
remote.PickNextUrl().ShouldBe("nats://127.0.0.1:7423");
|
||||
remote.PickNextUrl().ShouldBe("nats://127.0.0.1:7424");
|
||||
remote.PickNextUrl().ShouldBe("nats://127.0.0.1:7422");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoteLeafOptions_pick_next_url_without_entries_throws()
|
||||
{
|
||||
var remote = new RemoteLeafOptions();
|
||||
Should.Throw<InvalidOperationException>(() => remote.PickNextUrl());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoteLeafOptions_saves_tls_hostname_and_user_password_from_url()
|
||||
{
|
||||
var remote = new RemoteLeafOptions();
|
||||
|
||||
remote.SaveTlsHostname("nats://leaf.example.com:7422");
|
||||
remote.TlsName.ShouldBe("leaf.example.com");
|
||||
|
||||
remote.SaveUserPassword("nats://demo:secret@leaf.example.com:7422");
|
||||
remote.Username.ShouldBe("demo");
|
||||
remote.Password.ShouldBe("secret");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoteLeafOptions_connect_delay_round_trips()
|
||||
{
|
||||
var remote = new RemoteLeafOptions();
|
||||
remote.GetConnectDelay().ShouldBe(TimeSpan.Zero);
|
||||
|
||||
remote.SetConnectDelay(TimeSpan.FromSeconds(30));
|
||||
remote.GetConnectDelay().ShouldBe(TimeSpan.FromSeconds(30));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoteLeafNodeStillValid_checks_configured_and_disabled_remotes()
|
||||
{
|
||||
var manager = new LeafNodeManager(
|
||||
new LeafNodeOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Remotes = ["127.0.0.1:7422"],
|
||||
RemoteLeaves =
|
||||
[
|
||||
new RemoteLeafOptions
|
||||
{
|
||||
Urls = ["nats://127.0.0.1:7423"],
|
||||
},
|
||||
],
|
||||
},
|
||||
new ServerStats(),
|
||||
"S1",
|
||||
_ => { },
|
||||
_ => { },
|
||||
NullLogger<LeafNodeManager>.Instance);
|
||||
|
||||
manager.RemoteLeafNodeStillValid("127.0.0.1:7422").ShouldBeTrue();
|
||||
manager.RemoteLeafNodeStillValid("nats://127.0.0.1:7423").ShouldBeTrue();
|
||||
manager.RemoteLeafNodeStillValid("127.0.0.1:7999").ShouldBeFalse();
|
||||
|
||||
manager.DisableLeafConnect("127.0.0.1:7422");
|
||||
manager.RemoteLeafNodeStillValid("127.0.0.1:7422").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LeafNode_delay_constants_match_go_defaults()
|
||||
{
|
||||
LeafNodeManager.LeafNodeReconnectDelayAfterLoopDetected.ShouldBe(TimeSpan.FromSeconds(30));
|
||||
LeafNodeManager.LeafNodeReconnectAfterPermViolation.ShouldBe(TimeSpan.FromSeconds(30));
|
||||
LeafNodeManager.LeafNodeReconnectDelayAfterClusterNameSame.ShouldBe(TimeSpan.FromSeconds(30));
|
||||
LeafNodeManager.LeafNodeWaitBeforeClose.ShouldBe(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
private static LeafConnection CreateConnection()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var endpoint = (IPEndPoint)listener.LocalEndpoint;
|
||||
|
||||
var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
client.Connect(endpoint);
|
||||
|
||||
var server = listener.AcceptSocket();
|
||||
server.Dispose();
|
||||
listener.Stop();
|
||||
|
||||
return new LeafConnection(client);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using NATS.Server.Configuration;
|
||||
using NATS.Server.LeafNodes;
|
||||
|
||||
namespace NATS.Server.Tests.LeafNodes;
|
||||
|
||||
public class LeafConnectionParityBatch3Tests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SendLeafConnect_writes_connect_json_payload_with_expected_fields()
|
||||
{
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var endpoint = (IPEndPoint)listener.LocalEndpoint;
|
||||
|
||||
using var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await remoteSocket.ConnectAsync(IPAddress.Loopback, endpoint.Port);
|
||||
using var leafSocket = await listener.AcceptSocketAsync();
|
||||
await using var leaf = new LeafConnection(leafSocket);
|
||||
|
||||
var info = new LeafConnectInfo
|
||||
{
|
||||
Jwt = "jwt-token",
|
||||
Nkey = "nkey",
|
||||
Sig = "sig",
|
||||
Hub = true,
|
||||
Cluster = "C1",
|
||||
Headers = true,
|
||||
JetStream = true,
|
||||
Compression = "s2_auto",
|
||||
RemoteAccount = "A",
|
||||
Proto = 1,
|
||||
};
|
||||
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
await leaf.SendLeafConnectAsync(info, timeout.Token);
|
||||
|
||||
var line = await ReadLineAsync(remoteSocket, timeout.Token);
|
||||
line.ShouldStartWith("CONNECT ");
|
||||
|
||||
var payload = line["CONNECT ".Length..];
|
||||
using var json = JsonDocument.Parse(payload);
|
||||
var root = json.RootElement;
|
||||
|
||||
root.GetProperty("jwt").GetString().ShouldBe("jwt-token");
|
||||
root.GetProperty("nkey").GetString().ShouldBe("nkey");
|
||||
root.GetProperty("sig").GetString().ShouldBe("sig");
|
||||
root.GetProperty("hub").GetBoolean().ShouldBeTrue();
|
||||
root.GetProperty("cluster").GetString().ShouldBe("C1");
|
||||
root.GetProperty("headers").GetBoolean().ShouldBeTrue();
|
||||
root.GetProperty("jetstream").GetBoolean().ShouldBeTrue();
|
||||
root.GetProperty("compression").GetString().ShouldBe("s2_auto");
|
||||
root.GetProperty("remote_account").GetString().ShouldBe("A");
|
||||
root.GetProperty("proto").GetInt32().ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoteCluster_returns_cluster_from_leaf_handshake_attributes()
|
||||
{
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var endpoint = (IPEndPoint)listener.LocalEndpoint;
|
||||
|
||||
using var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await remoteSocket.ConnectAsync(IPAddress.Loopback, endpoint.Port);
|
||||
using var leafSocket = await listener.AcceptSocketAsync();
|
||||
await using var leaf = new LeafConnection(leafSocket);
|
||||
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var handshakeTask = leaf.PerformOutboundHandshakeAsync("LOCAL", timeout.Token);
|
||||
|
||||
(await ReadLineAsync(remoteSocket, timeout.Token)).ShouldBe("LEAF LOCAL");
|
||||
await WriteLineAsync(remoteSocket, "LEAF REMOTE cluster=HUB-A domain=JS-A", timeout.Token);
|
||||
|
||||
await handshakeTask;
|
||||
|
||||
leaf.RemoteId.ShouldBe("REMOTE");
|
||||
leaf.RemoteCluster().ShouldBe("HUB-A");
|
||||
leaf.RemoteJetStreamDomain.ShouldBe("JS-A");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetLeafConnectDelayIfSoliciting_sets_delay_only_for_solicited_connections()
|
||||
{
|
||||
await using var solicited = await CreateConnectionAsync();
|
||||
solicited.IsSolicited = true;
|
||||
solicited.SetLeafConnectDelayIfSoliciting(TimeSpan.FromSeconds(30));
|
||||
solicited.GetConnectDelay().ShouldBe(TimeSpan.FromSeconds(30));
|
||||
|
||||
await using var inbound = await CreateConnectionAsync();
|
||||
inbound.IsSolicited = false;
|
||||
inbound.SetLeafConnectDelayIfSoliciting(TimeSpan.FromSeconds(30));
|
||||
inbound.GetConnectDelay().ShouldBe(TimeSpan.Zero);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LeafProcessErr_maps_known_errors_to_reconnect_delays_on_solicited_connections()
|
||||
{
|
||||
await using var leaf = await CreateConnectionAsync();
|
||||
leaf.IsSolicited = true;
|
||||
|
||||
leaf.LeafProcessErr("Permissions Violation for Subscription to foo");
|
||||
leaf.GetConnectDelay().ShouldBe(LeafNodeManager.LeafNodeReconnectAfterPermViolation);
|
||||
|
||||
leaf.LeafProcessErr("Loop detected");
|
||||
leaf.GetConnectDelay().ShouldBe(LeafNodeManager.LeafNodeReconnectDelayAfterLoopDetected);
|
||||
|
||||
leaf.LeafProcessErr("Cluster name is same");
|
||||
leaf.GetConnectDelay().ShouldBe(LeafNodeManager.LeafNodeReconnectDelayAfterClusterNameSame);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LeafSubPermViolation_and_LeafPermViolation_set_permission_delay()
|
||||
{
|
||||
await using var leaf = await CreateConnectionAsync();
|
||||
leaf.IsSolicited = true;
|
||||
|
||||
leaf.LeafSubPermViolation("subj.A");
|
||||
leaf.GetConnectDelay().ShouldBe(LeafNodeManager.LeafNodeReconnectAfterPermViolation);
|
||||
|
||||
leaf.SetLeafConnectDelayIfSoliciting(TimeSpan.Zero);
|
||||
leaf.LeafPermViolation(pub: true, subj: "subj.B");
|
||||
leaf.GetConnectDelay().ShouldBe(LeafNodeManager.LeafNodeReconnectAfterPermViolation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancelMigrateTimer_stops_pending_timer_callback()
|
||||
{
|
||||
var remote = new RemoteLeafOptions();
|
||||
using var signal = new SemaphoreSlim(0, 1);
|
||||
|
||||
remote.StartMigrateTimer(_ => signal.Release(), TimeSpan.FromMilliseconds(120));
|
||||
remote.CancelMigrateTimer();
|
||||
|
||||
// The timer was disposed before its 120 ms deadline. We wait 300 ms via WaitAsync;
|
||||
// if the callback somehow fires it will release the semaphore and WaitAsync returns
|
||||
// true, which the assertion catches. No Task.Delay required.
|
||||
var fired = await signal.WaitAsync(TimeSpan.FromMilliseconds(300));
|
||||
fired.ShouldBeFalse();
|
||||
}
|
||||
|
||||
private static async Task<LeafConnection> CreateConnectionAsync()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var endpoint = (IPEndPoint)listener.LocalEndpoint;
|
||||
|
||||
var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await client.ConnectAsync(IPAddress.Loopback, endpoint.Port);
|
||||
|
||||
var server = await listener.AcceptSocketAsync();
|
||||
listener.Stop();
|
||||
client.Dispose();
|
||||
|
||||
return new LeafConnection(server);
|
||||
}
|
||||
|
||||
private static async Task<string> ReadLineAsync(Socket socket, CancellationToken ct)
|
||||
{
|
||||
var bytes = new List<byte>(64);
|
||||
var single = new byte[1];
|
||||
while (true)
|
||||
{
|
||||
var read = await socket.ReceiveAsync(single, SocketFlags.None, ct);
|
||||
if (read == 0)
|
||||
throw new IOException("Connection closed while reading line");
|
||||
if (single[0] == (byte)'\n')
|
||||
break;
|
||||
if (single[0] != (byte)'\r')
|
||||
bytes.Add(single[0]);
|
||||
}
|
||||
|
||||
return Encoding.ASCII.GetString([.. bytes]);
|
||||
}
|
||||
|
||||
private static Task WriteLineAsync(Socket socket, string line, CancellationToken ct)
|
||||
=> socket.SendAsync(Encoding.ASCII.GetBytes($"{line}\r\n"), SocketFlags.None, ct).AsTask();
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using NATS.Server.LeafNodes;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Tests.LeafNodes;
|
||||
|
||||
public class LeafConnectionParityBatch4Tests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SendLsPlus_with_queue_weight_writes_weighted_frame()
|
||||
{
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var endpoint = (IPEndPoint)listener.LocalEndpoint;
|
||||
|
||||
using var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await remoteSocket.ConnectAsync(IPAddress.Loopback, endpoint.Port);
|
||||
using var leafSocket = await listener.AcceptSocketAsync();
|
||||
await using var leaf = new LeafConnection(leafSocket);
|
||||
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var handshakeTask = leaf.PerformOutboundHandshakeAsync("LOCAL", timeout.Token);
|
||||
(await ReadLineAsync(remoteSocket, timeout.Token)).ShouldBe("LEAF LOCAL");
|
||||
await WriteLineAsync(remoteSocket, "LEAF REMOTE", timeout.Token);
|
||||
await handshakeTask;
|
||||
|
||||
await leaf.SendLsPlusAsync("$G", "jobs.>", "workers", queueWeight: 3, timeout.Token);
|
||||
(await ReadLineAsync(remoteSocket, timeout.Token)).ShouldBe("LS+ $G jobs.> workers 3");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadLoop_parses_queue_weight_from_ls_plus()
|
||||
{
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var endpoint = (IPEndPoint)listener.LocalEndpoint;
|
||||
|
||||
using var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await remoteSocket.ConnectAsync(IPAddress.Loopback, endpoint.Port);
|
||||
using var leafSocket = await listener.AcceptSocketAsync();
|
||||
await using var leaf = new LeafConnection(leafSocket);
|
||||
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var handshakeTask = leaf.PerformOutboundHandshakeAsync("LOCAL", timeout.Token);
|
||||
(await ReadLineAsync(remoteSocket, timeout.Token)).ShouldBe("LEAF LOCAL");
|
||||
await WriteLineAsync(remoteSocket, "LEAF REMOTE", timeout.Token);
|
||||
await handshakeTask;
|
||||
|
||||
var received = new List<RemoteSubscription>();
|
||||
leaf.RemoteSubscriptionReceived = sub =>
|
||||
{
|
||||
received.Add(sub);
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
leaf.StartLoop(timeout.Token);
|
||||
|
||||
await WriteLineAsync(remoteSocket, "LS+ $G jobs.> workers 7", timeout.Token);
|
||||
await WaitForAsync(() => received.Count >= 1, timeout.Token);
|
||||
|
||||
received[0].Subject.ShouldBe("jobs.>");
|
||||
received[0].Queue.ShouldBe("workers");
|
||||
received[0].QueueWeight.ShouldBe(7);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadLoop_defaults_invalid_queue_weight_to_one()
|
||||
{
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var endpoint = (IPEndPoint)listener.LocalEndpoint;
|
||||
|
||||
using var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await remoteSocket.ConnectAsync(IPAddress.Loopback, endpoint.Port);
|
||||
using var leafSocket = await listener.AcceptSocketAsync();
|
||||
await using var leaf = new LeafConnection(leafSocket);
|
||||
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var handshakeTask = leaf.PerformOutboundHandshakeAsync("LOCAL", timeout.Token);
|
||||
(await ReadLineAsync(remoteSocket, timeout.Token)).ShouldBe("LEAF LOCAL");
|
||||
await WriteLineAsync(remoteSocket, "LEAF REMOTE", timeout.Token);
|
||||
await handshakeTask;
|
||||
|
||||
var received = new List<RemoteSubscription>();
|
||||
leaf.RemoteSubscriptionReceived = sub =>
|
||||
{
|
||||
received.Add(sub);
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
leaf.StartLoop(timeout.Token);
|
||||
|
||||
await WriteLineAsync(remoteSocket, "LS+ $G jobs.> workers 0", timeout.Token);
|
||||
await WaitForAsync(() => received.Count >= 1, timeout.Token);
|
||||
|
||||
received[0].QueueWeight.ShouldBe(1);
|
||||
}
|
||||
|
||||
private static async Task<string> ReadLineAsync(Socket socket, CancellationToken ct)
|
||||
{
|
||||
var bytes = new List<byte>(64);
|
||||
var single = new byte[1];
|
||||
while (true)
|
||||
{
|
||||
var read = await socket.ReceiveAsync(single, SocketFlags.None, ct);
|
||||
if (read == 0)
|
||||
throw new IOException("Connection closed while reading line");
|
||||
if (single[0] == (byte)'\n')
|
||||
break;
|
||||
if (single[0] != (byte)'\r')
|
||||
bytes.Add(single[0]);
|
||||
}
|
||||
|
||||
return Encoding.ASCII.GetString([.. bytes]);
|
||||
}
|
||||
|
||||
private static Task WriteLineAsync(Socket socket, string line, CancellationToken ct)
|
||||
=> socket.SendAsync(Encoding.ASCII.GetBytes($"{line}\r\n"), SocketFlags.None, ct).AsTask();
|
||||
|
||||
private static async Task WaitForAsync(Func<bool> predicate, CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
if (predicate())
|
||||
return;
|
||||
|
||||
await Task.Yield();
|
||||
}
|
||||
|
||||
throw new TimeoutException("Timed out waiting for condition.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server.Configuration;
|
||||
using NATS.Server.LeafNodes;
|
||||
|
||||
namespace NATS.Server.Tests.LeafNodes;
|
||||
|
||||
public class LeafNodeManagerParityBatch5Tests
|
||||
{
|
||||
[Fact]
|
||||
[SlopwatchSuppress("SW004", "Delay verifies a blocked subject is NOT forwarded; absence of a frame cannot be observed via synchronization primitives")]
|
||||
public async Task PropagateLocalSubscription_enforces_spoke_subscribe_permissions_and_keeps_queue_weight()
|
||||
{
|
||||
await using var ctx = await CreateManagerWithInboundConnectionAsync();
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
var conn = ctx.Manager.GetConnectionByRemoteId("SPOKE1");
|
||||
conn.ShouldNotBeNull();
|
||||
conn!.IsSpoke = true;
|
||||
|
||||
var sync = ctx.Manager.SendPermsAndAccountInfo(
|
||||
ctx.ConnectionId,
|
||||
"$G",
|
||||
pubAllow: null,
|
||||
subAllow: ["allowed.>"]);
|
||||
sync.Found.ShouldBeTrue();
|
||||
sync.PermsSynced.ShouldBeTrue();
|
||||
|
||||
ctx.Manager.PropagateLocalSubscription("$G", "blocked.data", null);
|
||||
ctx.Manager.PropagateLocalSubscription("$G", "allowed.data", "workers", queueWeight: 4);
|
||||
|
||||
// Only the allowed subject should appear on the wire; the blocked one is filtered synchronously.
|
||||
var line = await ReadLineAsync(ctx.RemoteSocket, timeout.Token);
|
||||
line.ShouldBe("LS+ $G allowed.data workers 4");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PropagateLocalSubscription_allows_loop_and_gateway_reply_prefixes_for_spoke()
|
||||
{
|
||||
await using var ctx = await CreateManagerWithInboundConnectionAsync();
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
var conn = ctx.Manager.GetConnectionByRemoteId("SPOKE1");
|
||||
conn.ShouldNotBeNull();
|
||||
conn!.IsSpoke = true;
|
||||
|
||||
ctx.Manager.SendPermsAndAccountInfo(
|
||||
ctx.ConnectionId,
|
||||
"$G",
|
||||
pubAllow: null,
|
||||
subAllow: ["allowed.>"]);
|
||||
|
||||
ctx.Manager.PropagateLocalSubscription("$G", "$LDS.HUB.loop", null);
|
||||
(await ReadLineAsync(ctx.RemoteSocket, timeout.Token)).ShouldBe("LS+ $G $LDS.HUB.loop");
|
||||
|
||||
ctx.Manager.PropagateLocalSubscription("$G", "_GR_.A.reply", null);
|
||||
(await ReadLineAsync(ctx.RemoteSocket, timeout.Token)).ShouldBe("LS+ $G _GR_.A.reply");
|
||||
}
|
||||
|
||||
private sealed class ManagerContext : IAsyncDisposable
|
||||
{
|
||||
private readonly CancellationTokenSource _cts;
|
||||
|
||||
public ManagerContext(LeafNodeManager manager, string connectionId, Socket remoteSocket, CancellationTokenSource cts)
|
||||
{
|
||||
Manager = manager;
|
||||
ConnectionId = connectionId;
|
||||
RemoteSocket = remoteSocket;
|
||||
_cts = cts;
|
||||
}
|
||||
|
||||
public LeafNodeManager Manager { get; }
|
||||
public string ConnectionId { get; }
|
||||
public Socket RemoteSocket { get; }
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
RemoteSocket.Close();
|
||||
await Manager.DisposeAsync();
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<ManagerContext> CreateManagerWithInboundConnectionAsync()
|
||||
{
|
||||
var options = new LeafNodeOptions { Host = "127.0.0.1", Port = 0 };
|
||||
var manager = new LeafNodeManager(
|
||||
options,
|
||||
new ServerStats(),
|
||||
"HUB",
|
||||
_ => { },
|
||||
_ => { },
|
||||
NullLogger<LeafNodeManager>.Instance);
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
await manager.StartAsync(cts.Token);
|
||||
|
||||
var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await remoteSocket.ConnectAsync(IPAddress.Loopback, options.Port);
|
||||
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var registered = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
manager.OnConnectionRegistered = id => registered.TrySetResult(id);
|
||||
timeout.Token.Register(() => registered.TrySetCanceled(timeout.Token));
|
||||
|
||||
await WriteLineAsync(remoteSocket, "LEAF SPOKE1", timeout.Token);
|
||||
(await ReadLineAsync(remoteSocket, timeout.Token)).ShouldStartWith("LEAF ");
|
||||
|
||||
var connectionId = await registered.Task;
|
||||
return new ManagerContext(manager, connectionId, remoteSocket, cts);
|
||||
}
|
||||
|
||||
private static async Task<string> ReadLineAsync(Socket socket, CancellationToken ct)
|
||||
{
|
||||
var bytes = new List<byte>(64);
|
||||
var single = new byte[1];
|
||||
while (true)
|
||||
{
|
||||
var read = await socket.ReceiveAsync(single, SocketFlags.None, ct);
|
||||
if (read == 0)
|
||||
throw new IOException("Connection closed while reading line");
|
||||
if (single[0] == (byte)'\n')
|
||||
break;
|
||||
if (single[0] != (byte)'\r')
|
||||
bytes.Add(single[0]);
|
||||
}
|
||||
|
||||
return Encoding.ASCII.GetString([.. bytes]);
|
||||
}
|
||||
|
||||
private static Task WriteLineAsync(Socket socket, string line, CancellationToken ct)
|
||||
=> socket.SendAsync(Encoding.ASCII.GetBytes($"{line}\r\n"), SocketFlags.None, ct).AsTask();
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using NATS.Server.LeafNodes;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Tests.LeafNodes;
|
||||
|
||||
public class LeafSubKeyParityBatch2Tests
|
||||
{
|
||||
[Fact]
|
||||
public void Constants_match_go_leaf_key_and_delay_values()
|
||||
{
|
||||
LeafSubKey.KeyRoutedSub.ShouldBe("R");
|
||||
LeafSubKey.KeyRoutedSubByte.ShouldBe((byte)'R');
|
||||
LeafSubKey.KeyRoutedLeafSub.ShouldBe("L");
|
||||
LeafSubKey.KeyRoutedLeafSubByte.ShouldBe((byte)'L');
|
||||
LeafSubKey.SharedSysAccDelay.ShouldBe(TimeSpan.FromMilliseconds(250));
|
||||
LeafSubKey.ConnectProcessTimeout.ShouldBe(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeyFromSub_matches_go_subject_and_queue_shape()
|
||||
{
|
||||
LeafSubKey.KeyFromSub(NewSub("foo")).ShouldBe("foo");
|
||||
LeafSubKey.KeyFromSub(NewSub("foo", "bar")).ShouldBe("foo bar");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeyFromSubWithOrigin_matches_go_routed_and_leaf_routed_shapes()
|
||||
{
|
||||
LeafSubKey.KeyFromSubWithOrigin(NewSub("foo")).ShouldBe("R foo");
|
||||
LeafSubKey.KeyFromSubWithOrigin(NewSub("foo", "bar")).ShouldBe("R foo bar");
|
||||
LeafSubKey.KeyFromSubWithOrigin(NewSub("foo"), "leaf").ShouldBe("L foo leaf");
|
||||
LeafSubKey.KeyFromSubWithOrigin(NewSub("foo", "bar"), "leaf").ShouldBe("L foo bar leaf");
|
||||
}
|
||||
|
||||
private static Subscription NewSub(string subject, string? queue = null)
|
||||
=> new()
|
||||
{
|
||||
Subject = subject,
|
||||
Queue = queue,
|
||||
Sid = Guid.NewGuid().ToString("N"),
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using System.Text;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
@@ -8,14 +9,46 @@ public class ConnzParityFieldTests
|
||||
public async Task Connz_includes_identity_tls_and_proxy_parity_fields()
|
||||
{
|
||||
await using var fx = await MonitoringParityFixture.StartAsync();
|
||||
await fx.ConnectClientAsync("u", "orders.created");
|
||||
var jwt = BuildJwt("UISSUER", ["team:core", "tier:gold"]);
|
||||
await fx.ConnectClientAsync("proxy:edge", "orders.created", jwt);
|
||||
|
||||
var connz = fx.GetConnz("?subs=detail");
|
||||
var connz = fx.GetConnz("?subs=detail&auth=true");
|
||||
connz.Conns.ShouldNotBeEmpty();
|
||||
var conn = connz.Conns.Single(c => c.AuthorizedUser == "proxy:edge");
|
||||
conn.Proxy.ShouldNotBeNull();
|
||||
conn.Proxy.Key.ShouldBe("edge");
|
||||
conn.Jwt.ShouldBe(jwt);
|
||||
conn.IssuerKey.ShouldBe("UISSUER");
|
||||
conn.Tags.ShouldContain("team:core");
|
||||
|
||||
var json = JsonSerializer.Serialize(connz);
|
||||
json.ShouldContain("tls_peer_cert_subject");
|
||||
json.ShouldContain("jwt_issuer_key");
|
||||
json.ShouldContain("tls_peer_certs");
|
||||
json.ShouldContain("issuer_key");
|
||||
json.ShouldContain("\"tags\"");
|
||||
json.ShouldContain("proxy");
|
||||
json.ShouldNotContain("jwt_issuer_key");
|
||||
}
|
||||
|
||||
private static string BuildJwt(string issuer, string[] tags)
|
||||
{
|
||||
static string B64Url(string json)
|
||||
{
|
||||
return Convert.ToBase64String(Encoding.UTF8.GetBytes(json))
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
}
|
||||
|
||||
var header = B64Url("{\"alg\":\"none\",\"typ\":\"JWT\"}");
|
||||
var payload = B64Url(JsonSerializer.Serialize(new
|
||||
{
|
||||
iss = issuer,
|
||||
nats = new
|
||||
{
|
||||
tags,
|
||||
},
|
||||
}));
|
||||
return $"{header}.{payload}.eA";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ internal sealed class MonitoringParityFixture : IAsyncDisposable
|
||||
[
|
||||
new User { Username = "u", Password = "p", Account = "A" },
|
||||
new User { Username = "v", Password = "p", Account = "B" },
|
||||
new User { Username = "proxy:edge", Password = "p", Account = "A" },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -56,7 +57,7 @@ internal sealed class MonitoringParityFixture : IAsyncDisposable
|
||||
return new MonitoringParityFixture(server, options, cts);
|
||||
}
|
||||
|
||||
public async Task ConnectClientAsync(string username, string? subscribeSubject)
|
||||
public async Task ConnectClientAsync(string username, string? subscribeSubject, string? jwt = null)
|
||||
{
|
||||
var client = new TcpClient();
|
||||
await client.ConnectAsync(IPAddress.Loopback, _server.Port);
|
||||
@@ -65,7 +66,10 @@ internal sealed class MonitoringParityFixture : IAsyncDisposable
|
||||
var stream = client.GetStream();
|
||||
await ReadLineAsync(stream); // INFO
|
||||
|
||||
var connect = $"CONNECT {{\"user\":\"{username}\",\"pass\":\"p\"}}\r\n";
|
||||
var connectPayload = string.IsNullOrWhiteSpace(jwt)
|
||||
? $"{{\"user\":\"{username}\",\"pass\":\"p\"}}"
|
||||
: $"{{\"user\":\"{username}\",\"pass\":\"p\",\"jwt\":\"{jwt}\"}}";
|
||||
var connect = $"CONNECT {connectPayload}\r\n";
|
||||
await stream.WriteAsync(Encoding.ASCII.GetBytes(connect));
|
||||
if (!string.IsNullOrEmpty(subscribeSubject))
|
||||
await stream.WriteAsync(Encoding.ASCII.GetBytes($"SUB {subscribeSubject} sid-{username}\r\n"));
|
||||
@@ -82,7 +86,7 @@ internal sealed class MonitoringParityFixture : IAsyncDisposable
|
||||
|
||||
public async Task<Varz> GetVarzAsync()
|
||||
{
|
||||
using var handler = new VarzHandler(_server, _options);
|
||||
using var handler = new VarzHandler(_server, _options, NullLoggerFactory.Instance);
|
||||
return await handler.HandleVarzAsync();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Text.Json;
|
||||
using NATS.Server.Monitoring;
|
||||
|
||||
namespace NATS.Server.Tests.Monitoring;
|
||||
|
||||
public class MonitoringHealthAndSortParityBatch1Tests
|
||||
{
|
||||
[Fact]
|
||||
public void SortOpt_IsValid_matches_defined_values()
|
||||
{
|
||||
foreach (var value in Enum.GetValues<SortOpt>())
|
||||
value.IsValid().ShouldBeTrue();
|
||||
|
||||
((SortOpt)999).IsValid().ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HealthStatus_ok_serializes_with_go_shape_fields()
|
||||
{
|
||||
var json = JsonSerializer.Serialize(HealthStatus.Ok());
|
||||
|
||||
json.ShouldContain("\"status\":\"ok\"");
|
||||
json.ShouldContain("\"status_code\":200");
|
||||
json.ShouldContain("\"errors\":[]");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HealthzError_serializes_enum_as_string()
|
||||
{
|
||||
var json = JsonSerializer.Serialize(new HealthzError
|
||||
{
|
||||
Type = HealthzErrorType.JetStream,
|
||||
Error = "jetstream unavailable",
|
||||
});
|
||||
|
||||
json.ShouldContain("\"type\":\"JetStream\"");
|
||||
json.ShouldContain("\"error\":\"jetstream unavailable\"");
|
||||
}
|
||||
}
|
||||
65
tests/NATS.Server.Tests/Monitoring/TlsPeerCertParityTests.cs
Normal file
65
tests/NATS.Server.Tests/Monitoring/TlsPeerCertParityTests.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text.Json;
|
||||
using NATS.Server.Monitoring;
|
||||
|
||||
namespace NATS.Server.Tests.Monitoring;
|
||||
|
||||
public class TlsPeerCertParityTests
|
||||
{
|
||||
[Fact]
|
||||
public void TLSPeerCert_serializes_go_shape_fields()
|
||||
{
|
||||
var cert = new TLSPeerCert
|
||||
{
|
||||
Subject = "CN=peer",
|
||||
SubjectPKISha256 = new string('a', 64),
|
||||
CertSha256 = new string('b', 64),
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(cert);
|
||||
|
||||
json.ShouldContain("\"subject\":\"CN=peer\"");
|
||||
json.ShouldContain("\"subject_pk_sha256\":");
|
||||
json.ShouldContain("\"cert_sha256\":");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TlsPeerCertMapper_produces_subject_and_sha256_values_from_certificate()
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var req = new CertificateRequest("CN=peer", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
using var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(1));
|
||||
|
||||
var mapped = TlsPeerCertMapper.FromCertificate(cert);
|
||||
|
||||
mapped.Length.ShouldBe(1);
|
||||
mapped[0].Subject.ShouldContain("CN=peer");
|
||||
mapped[0].SubjectPKISha256.Length.ShouldBe(64);
|
||||
mapped[0].CertSha256.Length.ShouldBe(64);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConnInfo_json_includes_tls_peer_certs_array()
|
||||
{
|
||||
var info = new ConnInfo
|
||||
{
|
||||
Cid = 1,
|
||||
TlsPeerCertSubject = "CN=peer",
|
||||
TlsPeerCerts =
|
||||
[
|
||||
new TLSPeerCert
|
||||
{
|
||||
Subject = "CN=peer",
|
||||
SubjectPKISha256 = new string('c', 64),
|
||||
CertSha256 = new string('d', 64),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(info);
|
||||
json.ShouldContain("\"tls_peer_certs\":[");
|
||||
json.ShouldContain("\"subject_pk_sha256\":");
|
||||
json.ShouldContain("\"cert_sha256\":");
|
||||
}
|
||||
}
|
||||
92
tests/NATS.Server.Tests/Mqtt/MqttModelParityBatch3Tests.cs
Normal file
92
tests/NATS.Server.Tests/Mqtt/MqttModelParityBatch3Tests.cs
Normal file
@@ -0,0 +1,92 @@
|
||||
using NATS.Server.Mqtt;
|
||||
|
||||
namespace NATS.Server.Tests.Mqtt;
|
||||
|
||||
public class MqttModelParityBatch3Tests
|
||||
{
|
||||
[Fact]
|
||||
public void Mqtt_helper_models_cover_go_core_shapes()
|
||||
{
|
||||
var jsa = new MqttJsa
|
||||
{
|
||||
AccountName = "A",
|
||||
ReplyPrefix = "$MQTT.JSA.A",
|
||||
Domain = "D1",
|
||||
};
|
||||
|
||||
var pubMsg = new MqttJsPubMsg
|
||||
{
|
||||
Subject = "$MQTT.msgs.s1",
|
||||
Payload = new byte[] { 1, 2, 3 },
|
||||
ReplyTo = "$MQTT.JSA.A.reply",
|
||||
};
|
||||
|
||||
var delete = new MqttRetMsgDel
|
||||
{
|
||||
Topic = "devices/x",
|
||||
Sequence = 123,
|
||||
};
|
||||
|
||||
var persisted = new MqttPersistedSession
|
||||
{
|
||||
ClientId = "c1",
|
||||
LastPacketId = 7,
|
||||
MaxAckPending = 1024,
|
||||
};
|
||||
|
||||
var retainedRef = new MqttRetainedMessageRef
|
||||
{
|
||||
StreamSequence = 88,
|
||||
Subject = "$MQTT.rmsgs.devices/x",
|
||||
};
|
||||
|
||||
var sub = new MqttSub
|
||||
{
|
||||
Filter = "devices/+",
|
||||
Qos = 1,
|
||||
JsDur = "DUR-c1",
|
||||
Prm = true,
|
||||
Reserved = false,
|
||||
};
|
||||
|
||||
var filter = new MqttFilter
|
||||
{
|
||||
Filter = "devices/#",
|
||||
Qos = 1,
|
||||
TopicToken = "devices",
|
||||
};
|
||||
|
||||
var parsedHeader = new MqttParsedPublishNatsHeader
|
||||
{
|
||||
Subject = "devices/x",
|
||||
Mapped = "devices.y",
|
||||
IsPublish = true,
|
||||
IsPubRel = false,
|
||||
};
|
||||
|
||||
jsa.AccountName.ShouldBe("A");
|
||||
pubMsg.Payload.ShouldBe(new byte[] { 1, 2, 3 });
|
||||
delete.Sequence.ShouldBe(123UL);
|
||||
persisted.MaxAckPending.ShouldBe(1024);
|
||||
retainedRef.StreamSequence.ShouldBe(88UL);
|
||||
sub.JsDur.ShouldBe("DUR-c1");
|
||||
filter.TopicToken.ShouldBe("devices");
|
||||
parsedHeader.IsPublish.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Retained_message_model_includes_origin_flags_and_source_fields()
|
||||
{
|
||||
var msg = new MqttRetainedMessage(
|
||||
Topic: "devices/x",
|
||||
Payload: new byte[] { 0x41, 0x42 },
|
||||
Origin: "origin-a",
|
||||
Flags: 0b_0000_0011,
|
||||
Source: "src-a");
|
||||
|
||||
msg.Topic.ShouldBe("devices/x");
|
||||
msg.Origin.ShouldBe("origin-a");
|
||||
msg.Flags.ShouldBe((byte)0b_0000_0011);
|
||||
msg.Source.ShouldBe("src-a");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using System.Text;
|
||||
using NATS.Server.Mqtt;
|
||||
|
||||
namespace NATS.Server.Tests.Mqtt;
|
||||
|
||||
public class MqttProtocolConstantsParityBatch1Tests
|
||||
{
|
||||
[Fact]
|
||||
public void Constants_match_mqtt_go_reference_values()
|
||||
{
|
||||
MqttProtocolConstants.SubscribeFlags.ShouldBe((byte)0x02);
|
||||
|
||||
MqttProtocolConstants.ConnAckAccepted.ShouldBe((byte)0x00);
|
||||
MqttProtocolConstants.ConnAckUnacceptableProtocolVersion.ShouldBe((byte)0x01);
|
||||
MqttProtocolConstants.ConnAckIdentifierRejected.ShouldBe((byte)0x02);
|
||||
MqttProtocolConstants.ConnAckServerUnavailable.ShouldBe((byte)0x03);
|
||||
MqttProtocolConstants.ConnAckBadUserNameOrPassword.ShouldBe((byte)0x04);
|
||||
MqttProtocolConstants.ConnAckNotAuthorized.ShouldBe((byte)0x05);
|
||||
|
||||
MqttProtocolConstants.MaxPayloadSize.ShouldBe(268_435_455);
|
||||
MqttProtocolConstants.DefaultAckWait.ShouldBe(TimeSpan.FromSeconds(30));
|
||||
MqttProtocolConstants.MaxAckTotalLimit.ShouldBe(0xFFFF);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseSubscribe_accepts_required_subscribe_flags()
|
||||
{
|
||||
var payload = CreateSubscribePayload(packetId: 7, ("sport/tennis/#", 1));
|
||||
|
||||
var info = MqttBinaryDecoder.ParseSubscribe(payload, flags: MqttProtocolConstants.SubscribeFlags);
|
||||
|
||||
info.PacketId.ShouldBe((ushort)7);
|
||||
info.Filters.Count.ShouldBe(1);
|
||||
info.Filters[0].TopicFilter.ShouldBe("sport/tennis/#");
|
||||
info.Filters[0].QoS.ShouldBe((byte)1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseSubscribe_rejects_invalid_subscribe_flags()
|
||||
{
|
||||
var payload = CreateSubscribePayload(packetId: 5, ("topic/one", 0));
|
||||
|
||||
var ex = Should.Throw<FormatException>(() => MqttBinaryDecoder.ParseSubscribe(payload, flags: 0x00));
|
||||
ex.Message.ShouldContain("invalid fixed-header flags");
|
||||
}
|
||||
|
||||
private static byte[] CreateSubscribePayload(ushort packetId, params (string Topic, byte Qos)[] filters)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
using var writer = new BinaryWriter(ms);
|
||||
|
||||
WriteUInt16BigEndian(writer, packetId);
|
||||
foreach (var (topic, qos) in filters)
|
||||
{
|
||||
WriteString(writer, topic);
|
||||
writer.Write(qos);
|
||||
}
|
||||
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
private static void WriteString(BinaryWriter writer, string value)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(value);
|
||||
WriteUInt16BigEndian(writer, (ushort)bytes.Length);
|
||||
writer.Write(bytes);
|
||||
}
|
||||
|
||||
private static void WriteUInt16BigEndian(BinaryWriter writer, ushort value)
|
||||
{
|
||||
writer.Write((byte)(value >> 8));
|
||||
writer.Write((byte)(value & 0xFF));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using System.Text;
|
||||
using NATS.Server.Mqtt;
|
||||
|
||||
namespace NATS.Server.Tests.Mqtt;
|
||||
|
||||
public class MqttProtocolConstantsParityBatch2Tests
|
||||
{
|
||||
[Fact]
|
||||
public void Extended_constants_match_go_reference_values()
|
||||
{
|
||||
MqttProtocolConstants.MultiLevelSidSuffix.ShouldBe(" fwc");
|
||||
MqttProtocolConstants.Prefix.ShouldBe("$MQTT.");
|
||||
MqttProtocolConstants.SubPrefix.ShouldBe("$MQTT.sub.");
|
||||
|
||||
MqttProtocolConstants.StreamName.ShouldBe("$MQTT_msgs");
|
||||
MqttProtocolConstants.StreamSubjectPrefix.ShouldBe("$MQTT.msgs.");
|
||||
MqttProtocolConstants.RetainedMsgsStreamName.ShouldBe("$MQTT_rmsgs");
|
||||
MqttProtocolConstants.RetainedMsgsStreamSubject.ShouldBe("$MQTT.rmsgs.");
|
||||
MqttProtocolConstants.SessStreamName.ShouldBe("$MQTT_sess");
|
||||
MqttProtocolConstants.SessStreamSubjectPrefix.ShouldBe("$MQTT.sess.");
|
||||
MqttProtocolConstants.SessionsStreamNamePrefix.ShouldBe("$MQTT_sess_");
|
||||
MqttProtocolConstants.QoS2IncomingMsgsStreamName.ShouldBe("$MQTT_qos2in");
|
||||
MqttProtocolConstants.QoS2IncomingMsgsStreamSubjectPrefix.ShouldBe("$MQTT.qos2.in.");
|
||||
|
||||
MqttProtocolConstants.OutStreamName.ShouldBe("$MQTT_out");
|
||||
MqttProtocolConstants.OutSubjectPrefix.ShouldBe("$MQTT.out.");
|
||||
MqttProtocolConstants.PubRelSubjectPrefix.ShouldBe("$MQTT.out.pubrel.");
|
||||
MqttProtocolConstants.PubRelDeliverySubjectPrefix.ShouldBe("$MQTT.deliver.pubrel.");
|
||||
MqttProtocolConstants.PubRelConsumerDurablePrefix.ShouldBe("$MQTT_PUBREL_");
|
||||
|
||||
MqttProtocolConstants.JSARepliesPrefix.ShouldBe("$MQTT.JSA.");
|
||||
MqttProtocolConstants.JSAIdTokenPos.ShouldBe(3);
|
||||
MqttProtocolConstants.JSATokenPos.ShouldBe(4);
|
||||
MqttProtocolConstants.JSAClientIDPos.ShouldBe(5);
|
||||
MqttProtocolConstants.JSAStreamCreate.ShouldBe("SC");
|
||||
MqttProtocolConstants.JSAStreamUpdate.ShouldBe("SU");
|
||||
MqttProtocolConstants.JSAStreamLookup.ShouldBe("SL");
|
||||
MqttProtocolConstants.JSAStreamDel.ShouldBe("SD");
|
||||
MqttProtocolConstants.JSAConsumerCreate.ShouldBe("CC");
|
||||
MqttProtocolConstants.JSAConsumerLookup.ShouldBe("CL");
|
||||
MqttProtocolConstants.JSAConsumerDel.ShouldBe("CD");
|
||||
MqttProtocolConstants.JSAMsgStore.ShouldBe("MS");
|
||||
MqttProtocolConstants.JSAMsgLoad.ShouldBe("ML");
|
||||
MqttProtocolConstants.JSAMsgDelete.ShouldBe("MD");
|
||||
MqttProtocolConstants.JSASessPersist.ShouldBe("SP");
|
||||
MqttProtocolConstants.JSARetainedMsgDel.ShouldBe("RD");
|
||||
MqttProtocolConstants.JSAStreamNames.ShouldBe("SN");
|
||||
|
||||
MqttProtocolConstants.SparkbNBirth.ShouldBe("NBIRTH");
|
||||
MqttProtocolConstants.SparkbDBirth.ShouldBe("DBIRTH");
|
||||
MqttProtocolConstants.SparkbNDeath.ShouldBe("NDEATH");
|
||||
MqttProtocolConstants.SparkbDDeath.ShouldBe("DDEATH");
|
||||
Encoding.ASCII.GetString(MqttProtocolConstants.SparkbNamespaceTopicPrefix).ShouldBe("spBv1.0/");
|
||||
Encoding.ASCII.GetString(MqttProtocolConstants.SparkbCertificatesTopicPrefix).ShouldBe("$sparkplug/certificates/");
|
||||
|
||||
MqttProtocolConstants.NatsHeaderPublish.ShouldBe("Nmqtt-Pub");
|
||||
MqttProtocolConstants.NatsRetainedMessageTopic.ShouldBe("Nmqtt-RTopic");
|
||||
MqttProtocolConstants.NatsRetainedMessageOrigin.ShouldBe("Nmqtt-ROrigin");
|
||||
MqttProtocolConstants.NatsRetainedMessageFlags.ShouldBe("Nmqtt-RFlags");
|
||||
MqttProtocolConstants.NatsRetainedMessageSource.ShouldBe("Nmqtt-RSource");
|
||||
MqttProtocolConstants.NatsPubRelHeader.ShouldBe("Nmqtt-PubRel");
|
||||
MqttProtocolConstants.NatsHeaderSubject.ShouldBe("Nmqtt-Subject");
|
||||
MqttProtocolConstants.NatsHeaderMapped.ShouldBe("Nmqtt-Mapped");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteString_writes_length_prefixed_utf8()
|
||||
{
|
||||
var encoded = MqttPacketWriter.WriteString("MQTT");
|
||||
|
||||
encoded.Length.ShouldBe(6);
|
||||
encoded[0].ShouldBe((byte)0x00);
|
||||
encoded[1].ShouldBe((byte)0x04);
|
||||
Encoding.UTF8.GetString(encoded.AsSpan(2)).ShouldBe("MQTT");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteBytes_writes_length_prefixed_binary_payload()
|
||||
{
|
||||
var encoded = MqttPacketWriter.WriteBytes(new byte[] { 0xAA, 0xBB, 0xCC });
|
||||
|
||||
encoded.ShouldBe(new byte[] { 0x00, 0x03, 0xAA, 0xBB, 0xCC });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteBytes_rejects_payload_larger_than_uint16()
|
||||
{
|
||||
var payload = new byte[ushort.MaxValue + 1];
|
||||
Should.Throw<ArgumentOutOfRangeException>(() => MqttPacketWriter.WriteBytes(payload));
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server;
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Monitoring;
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
@@ -717,6 +718,121 @@ public class MsgTraceGoParityTests : IAsyncLifetime
|
||||
await cts.CancelAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Username/password authorization violations are tracked in closed connections.
|
||||
/// Go: TestClosedUPAuthorizationViolation (closed_conns_test.go:187)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ClosedConns_up_auth_violation_close_reason_tracked()
|
||||
{
|
||||
// Go: TestClosedUPAuthorizationViolation (closed_conns_test.go:187)
|
||||
var port = GetFreePort();
|
||||
using var cts = new CancellationTokenSource();
|
||||
using var server = new NatsServer(
|
||||
new NatsOptions
|
||||
{
|
||||
Port = port,
|
||||
Users =
|
||||
[
|
||||
new User { Username = "my_user", Password = "my_secret" },
|
||||
],
|
||||
},
|
||||
NullLoggerFactory.Instance);
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
// No credentials
|
||||
using (var conn1 = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp))
|
||||
{
|
||||
await conn1.ConnectAsync(IPAddress.Loopback, port);
|
||||
await ReadUntilAsync(conn1, "\r\n"); // INFO
|
||||
await conn1.SendAsync("CONNECT {\"verbose\":false}\r\nPING\r\n"u8.ToArray());
|
||||
await ReadUntilAsync(conn1, "-ERR", 2000);
|
||||
}
|
||||
|
||||
// Wrong password
|
||||
using (var conn2 = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp))
|
||||
{
|
||||
await conn2.ConnectAsync(IPAddress.Loopback, port);
|
||||
await ReadUntilAsync(conn2, "\r\n"); // INFO
|
||||
await conn2.SendAsync(
|
||||
"CONNECT {\"verbose\":false,\"user\":\"my_user\",\"pass\":\"wrong_pass\"}\r\nPING\r\n"u8.ToArray());
|
||||
await ReadUntilAsync(conn2, "-ERR", 2000);
|
||||
}
|
||||
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (server.GetClosedClients().Count >= 2)
|
||||
break;
|
||||
await Task.Delay(10);
|
||||
}
|
||||
|
||||
var conns = server.GetClosedClients().ToList();
|
||||
conns.Count.ShouldBeGreaterThanOrEqualTo(2);
|
||||
conns.Take(2).All(c => c.Reason.Contains("Authorization Violation", StringComparison.OrdinalIgnoreCase))
|
||||
.ShouldBeTrue();
|
||||
|
||||
await cts.CancelAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TLS handshake failures are tracked in closed connections with the TLS reason.
|
||||
/// Go: TestClosedTLSHandshake (closed_conns_test.go:247)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ClosedConns_tls_handshake_close_reason_tracked()
|
||||
{
|
||||
// Go: TestClosedTLSHandshake (closed_conns_test.go:247)
|
||||
var (certPath, keyPath) = TlsHelperTests.GenerateTestCertFiles();
|
||||
try
|
||||
{
|
||||
var port = GetFreePort();
|
||||
using var cts = new CancellationTokenSource();
|
||||
using var server = new NatsServer(
|
||||
new NatsOptions
|
||||
{
|
||||
Port = port,
|
||||
TlsCert = certPath,
|
||||
TlsKey = keyPath,
|
||||
TlsVerify = true,
|
||||
AllowNonTls = false,
|
||||
},
|
||||
NullLoggerFactory.Instance);
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
// Plain TCP client against TLS-required port should fail handshake.
|
||||
using (var conn = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp))
|
||||
{
|
||||
await conn.ConnectAsync(IPAddress.Loopback, port);
|
||||
await ReadUntilAsync(conn, "\r\n"); // INFO
|
||||
await conn.SendAsync("CONNECT {\"verbose\":false}\r\nPING\r\n"u8.ToArray());
|
||||
_ = await ReadUntilAsync(conn, "-ERR", 1000);
|
||||
}
|
||||
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (server.GetClosedClients().Any())
|
||||
break;
|
||||
await Task.Delay(10);
|
||||
}
|
||||
|
||||
var conns = server.GetClosedClients().ToList();
|
||||
conns.Count.ShouldBeGreaterThan(0);
|
||||
conns.Any(c => c.Reason.Contains("TLS Handshake Error", StringComparison.OrdinalIgnoreCase))
|
||||
.ShouldBeTrue();
|
||||
|
||||
await cts.CancelAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(certPath);
|
||||
File.Delete(keyPath);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── ClosedState enum (closed_conns_test.go — checkReason) ───────────────
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -83,6 +83,14 @@ public class NatsConfLexerTests
|
||||
keys[0].Value.ShouldBe("foo");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lex_CommentBody_EmitsTextToken()
|
||||
{
|
||||
var tokens = NatsConfLexer.Tokenize("# this is a comment\nfoo = 1").ToList();
|
||||
var commentBody = tokens.Single(t => t.Type == TokenType.Text);
|
||||
commentBody.Value.ShouldBe(" this is a comment");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lex_SlashComment_IsIgnored()
|
||||
{
|
||||
@@ -218,4 +226,13 @@ public class NatsConfLexerTests
|
||||
tokens[1].Type.ShouldBe(TokenType.String);
|
||||
tokens[1].Value.ShouldBe("3xyz");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lex_Unicode_surrogate_pairs_in_strings_are_preserved()
|
||||
{
|
||||
var tokens = NatsConfLexer.Tokenize("msg = \"rocket🚀\"\nport = 4222").ToList();
|
||||
tokens[1].Type.ShouldBe(TokenType.String);
|
||||
tokens[1].Value.ShouldBe("rocket🚀");
|
||||
tokens[2].Line.ShouldBe(2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,4 +275,15 @@ public class ParserTests
|
||||
var ex = await ParseExpectingErrorAsync(input);
|
||||
ex.ShouldBeOfType<ProtocolViolationException>();
|
||||
}
|
||||
|
||||
// Mirrors Go TestParsePubSizeOverflow: oversized decimal payload lengths
|
||||
// must be rejected during PUB argument parsing.
|
||||
// Reference: golang/nats-server/server/parser_test.go TestParsePubSizeOverflow
|
||||
[Fact]
|
||||
public async Task Parse_pub_size_overflow_fails()
|
||||
{
|
||||
var ex = await ParseExpectingErrorAsync("PUB foo 1234567890\r\n");
|
||||
ex.ShouldBeOfType<ProtocolViolationException>();
|
||||
ex.Message.ShouldContain("Invalid payload size");
|
||||
}
|
||||
}
|
||||
|
||||
87
tests/NATS.Server.Tests/Protocol/ProtoWireParityTests.cs
Normal file
87
tests/NATS.Server.Tests/Protocol/ProtoWireParityTests.cs
Normal file
@@ -0,0 +1,87 @@
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Tests.ProtocolParity;
|
||||
|
||||
public class ProtoWireParityTests
|
||||
{
|
||||
[Fact]
|
||||
public void ScanField_reads_tag_and_value_size_for_length_delimited_field()
|
||||
{
|
||||
// field=2, type=2, len=3, bytes=abc
|
||||
byte[] bytes = [0x12, 0x03, (byte)'a', (byte)'b', (byte)'c'];
|
||||
|
||||
var (number, wireType, size) = ProtoWire.ScanField(bytes);
|
||||
|
||||
number.ShouldBe(2);
|
||||
wireType.ShouldBe(2);
|
||||
size.ShouldBe(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanTag_rejects_invalid_field_numbers()
|
||||
{
|
||||
var zeroFieldEx = Should.Throw<ProtoWireException>(() => ProtoWire.ScanTag([0x00]));
|
||||
zeroFieldEx.Message.ShouldBe(ProtoWire.ErrProtoInvalidFieldNumber);
|
||||
|
||||
var tooLargeTag = ProtoWire.EncodeVarint(((ulong)int.MaxValue + 1UL) << 3);
|
||||
var tooLargeEx = Should.Throw<ProtoWireException>(() => ProtoWire.ScanTag(tooLargeTag));
|
||||
tooLargeEx.Message.ShouldBe(ProtoWire.ErrProtoInvalidFieldNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanFieldValue_supports_expected_wire_types()
|
||||
{
|
||||
ProtoWire.ScanFieldValue(5, [0, 0, 0, 0]).ShouldBe(4);
|
||||
ProtoWire.ScanFieldValue(1, [0, 0, 0, 0, 0, 0, 0, 0]).ShouldBe(8);
|
||||
ProtoWire.ScanFieldValue(0, [0x01]).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanFieldValue_rejects_unsupported_wire_type()
|
||||
{
|
||||
var ex = Should.Throw<ProtoWireException>(() => ProtoWire.ScanFieldValue(3, [0x00]));
|
||||
ex.Message.ShouldBe("unsupported type: 3");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanVarint_reports_insufficient_and_overflow_errors()
|
||||
{
|
||||
var insufficient = Should.Throw<ProtoWireException>(() => ProtoWire.ScanVarint([0x80]));
|
||||
insufficient.Message.ShouldBe(ProtoWire.ErrProtoInsufficient);
|
||||
|
||||
byte[] overflow = [0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x02];
|
||||
var tooLarge = Should.Throw<ProtoWireException>(() => ProtoWire.ScanVarint(overflow));
|
||||
tooLarge.Message.ShouldBe(ProtoWire.ErrProtoOverflow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanBytes_reports_insufficient_when_length_prefix_exceeds_payload()
|
||||
{
|
||||
var ex = Should.Throw<ProtoWireException>(() => ProtoWire.ScanBytes([0x04, 0x01, 0x02]));
|
||||
ex.Message.ShouldBe(ProtoWire.ErrProtoInsufficient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncodeVarint_round_trips_values_via_scan_varint()
|
||||
{
|
||||
ulong[] values =
|
||||
[
|
||||
0UL,
|
||||
1UL,
|
||||
127UL,
|
||||
128UL,
|
||||
16_383UL,
|
||||
16_384UL,
|
||||
(1UL << 32) - 1,
|
||||
ulong.MaxValue,
|
||||
];
|
||||
|
||||
foreach (var value in values)
|
||||
{
|
||||
var encoded = ProtoWire.EncodeVarint(value);
|
||||
var (decoded, size) = ProtoWire.ScanVarint(encoded);
|
||||
decoded.ShouldBe(value);
|
||||
size.ShouldBe(encoded.Length);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using NATS.Server;
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Tests.ProtocolParity;
|
||||
|
||||
public class ProtocolDefaultConstantsGapParityTests
|
||||
{
|
||||
[Fact]
|
||||
public void NatsProtocol_exposes_core_default_constants()
|
||||
{
|
||||
NatsProtocol.DefaultHost.ShouldBe("0.0.0.0");
|
||||
NatsProtocol.DefaultHttpPort.ShouldBe(8222);
|
||||
NatsProtocol.DefaultHttpBasePath.ShouldBe("/");
|
||||
NatsProtocol.DefaultRoutePoolSize.ShouldBe(3);
|
||||
NatsProtocol.DefaultLeafNodePort.ShouldBe(7422);
|
||||
NatsProtocol.MaxPayloadMaxSize.ShouldBe(8 * 1024 * 1024);
|
||||
NatsProtocol.DefaultMaxConnections.ShouldBe(64 * 1024);
|
||||
NatsProtocol.DefaultPingMaxOut.ShouldBe(2);
|
||||
NatsProtocol.DefaultMaxClosedClients.ShouldBe(10_000);
|
||||
NatsProtocol.DefaultConnectErrorReports.ShouldBe(3600);
|
||||
NatsProtocol.DefaultReconnectErrorReports.ShouldBe(1);
|
||||
NatsProtocol.DefaultAllowResponseMaxMsgs.ShouldBe(1);
|
||||
NatsProtocol.DefaultServiceLatencySampling.ShouldBe(100);
|
||||
NatsProtocol.DefaultSystemAccount.ShouldBe("$SYS");
|
||||
NatsProtocol.DefaultGlobalAccount.ShouldBe("$G");
|
||||
NatsProtocol.ProtoSnippetSize.ShouldBe(32);
|
||||
NatsProtocol.MaxControlLineSnippetSize.ShouldBe(128);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NatsProtocol_exposes_core_default_timespans()
|
||||
{
|
||||
NatsProtocol.TlsTimeout.ShouldBe(TimeSpan.FromSeconds(2));
|
||||
NatsProtocol.DefaultTlsHandshakeFirstFallbackDelay.ShouldBe(TimeSpan.FromMilliseconds(50));
|
||||
NatsProtocol.AuthTimeout.ShouldBe(TimeSpan.FromSeconds(2));
|
||||
NatsProtocol.DefaultRouteConnect.ShouldBe(TimeSpan.FromSeconds(1));
|
||||
NatsProtocol.DefaultRouteConnectMax.ShouldBe(TimeSpan.FromSeconds(30));
|
||||
NatsProtocol.DefaultRouteReconnect.ShouldBe(TimeSpan.FromSeconds(1));
|
||||
NatsProtocol.DefaultRouteDial.ShouldBe(TimeSpan.FromSeconds(1));
|
||||
NatsProtocol.DefaultLeafNodeReconnect.ShouldBe(TimeSpan.FromSeconds(1));
|
||||
NatsProtocol.DefaultLeafTlsTimeout.ShouldBe(TimeSpan.FromSeconds(2));
|
||||
NatsProtocol.DefaultLeafNodeInfoWait.ShouldBe(TimeSpan.FromSeconds(1));
|
||||
NatsProtocol.DefaultRttMeasurementInterval.ShouldBe(TimeSpan.FromHours(1));
|
||||
NatsProtocol.DefaultAllowResponseExpiration.ShouldBe(TimeSpan.FromMinutes(2));
|
||||
NatsProtocol.DefaultServiceExportResponseThreshold.ShouldBe(TimeSpan.FromMinutes(2));
|
||||
NatsProtocol.DefaultAccountFetchTimeout.ShouldBe(TimeSpan.FromMilliseconds(1900));
|
||||
NatsProtocol.DefaultPingInterval.ShouldBe(TimeSpan.FromMinutes(2));
|
||||
NatsProtocol.DefaultFlushDeadline.ShouldBe(TimeSpan.FromSeconds(10));
|
||||
NatsProtocol.AcceptMinSleep.ShouldBe(TimeSpan.FromMilliseconds(10));
|
||||
NatsProtocol.AcceptMaxSleep.ShouldBe(TimeSpan.FromSeconds(1));
|
||||
NatsProtocol.DefaultLameDuckDuration.ShouldBe(TimeSpan.FromMinutes(2));
|
||||
NatsProtocol.DefaultLameDuckGracePeriod.ShouldBe(TimeSpan.FromSeconds(10));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NatsOptions_defaults_are_bound_to_protocol_defaults()
|
||||
{
|
||||
var options = new NatsOptions();
|
||||
|
||||
options.Host.ShouldBe(NatsProtocol.DefaultHost);
|
||||
options.Port.ShouldBe(NatsProtocol.DefaultPort);
|
||||
options.MaxConnections.ShouldBe(NatsProtocol.DefaultMaxConnections);
|
||||
options.AuthTimeout.ShouldBe(NatsProtocol.AuthTimeout);
|
||||
options.PingInterval.ShouldBe(NatsProtocol.DefaultPingInterval);
|
||||
options.MaxPingsOut.ShouldBe(NatsProtocol.DefaultPingMaxOut);
|
||||
options.WriteDeadline.ShouldBe(NatsProtocol.DefaultFlushDeadline);
|
||||
options.TlsTimeout.ShouldBe(NatsProtocol.TlsTimeout);
|
||||
options.TlsHandshakeFirstFallback.ShouldBe(NatsProtocol.DefaultTlsHandshakeFirstFallbackDelay);
|
||||
options.MaxClosedClients.ShouldBe(NatsProtocol.DefaultMaxClosedClients);
|
||||
options.LameDuckDuration.ShouldBe(NatsProtocol.DefaultLameDuckDuration);
|
||||
options.LameDuckGracePeriod.ShouldBe(NatsProtocol.DefaultLameDuckGracePeriod);
|
||||
options.ConnectErrorReports.ShouldBe(NatsProtocol.DefaultConnectErrorReports);
|
||||
options.ReconnectErrorReports.ShouldBe(NatsProtocol.DefaultReconnectErrorReports);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Buffers;
|
||||
using System.Text;
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Tests.ProtocolParity;
|
||||
|
||||
public class ProtocolParserSnippetGapParityTests
|
||||
{
|
||||
[Fact]
|
||||
public void ProtoSnippet_returns_empty_quotes_when_start_is_out_of_range()
|
||||
{
|
||||
var bytes = "PING"u8.ToArray();
|
||||
var snippet = NatsParser.ProtoSnippet(bytes.Length, 2, bytes);
|
||||
snippet.ShouldBe("\"\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProtoSnippet_limits_to_requested_window_and_quotes_output()
|
||||
{
|
||||
var bytes = "ABCDEFGHIJ"u8.ToArray();
|
||||
var snippet = NatsParser.ProtoSnippet(2, 4, bytes);
|
||||
snippet.ShouldBe("\"CDEF\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProtoSnippet_matches_go_behavior_when_max_runs_past_buffer_end()
|
||||
{
|
||||
var bytes = "ABCDE"u8.ToArray();
|
||||
var snippet = NatsParser.ProtoSnippet(0, 32, bytes);
|
||||
snippet.ShouldBe("\"ABCD\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_exceeding_max_control_line_includes_snippet_context_in_error()
|
||||
{
|
||||
var parser = new NatsParser();
|
||||
var longSubject = new string('a', NatsProtocol.MaxControlLineSize + 1);
|
||||
var input = Encoding.ASCII.GetBytes($"PUB {longSubject} 0\r\n\r\n");
|
||||
ReadOnlySequence<byte> buffer = new(input);
|
||||
|
||||
var ex = Should.Throw<ProtocolViolationException>(() => parser.TryParse(ref buffer, out _));
|
||||
ex.Message.ShouldContain("Maximum control line exceeded");
|
||||
ex.Message.ShouldContain("snip=");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using NATS.Server.Raft;
|
||||
|
||||
namespace NATS.Server.Tests.Raft;
|
||||
|
||||
public class RaftConfigAndStateParityBatch1Tests
|
||||
{
|
||||
[Fact]
|
||||
public void RaftState_string_matches_go_labels()
|
||||
{
|
||||
RaftState.Follower.String().ShouldBe("Follower");
|
||||
RaftState.Leader.String().ShouldBe("Leader");
|
||||
RaftState.Candidate.String().ShouldBe("Candidate");
|
||||
RaftState.Closed.String().ShouldBe("Closed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RaftConfig_exposes_go_shape_fields()
|
||||
{
|
||||
var cfg = new RaftConfig
|
||||
{
|
||||
Name = "META",
|
||||
Store = new object(),
|
||||
Log = new object(),
|
||||
Track = true,
|
||||
Observer = true,
|
||||
Recovering = true,
|
||||
ScaleUp = true,
|
||||
};
|
||||
|
||||
cfg.Name.ShouldBe("META");
|
||||
cfg.Store.ShouldNotBeNull();
|
||||
cfg.Log.ShouldNotBeNull();
|
||||
cfg.Track.ShouldBeTrue();
|
||||
cfg.Observer.ShouldBeTrue();
|
||||
cfg.Recovering.ShouldBeTrue();
|
||||
cfg.ScaleUp.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RaftNode_group_defaults_to_id_when_not_supplied()
|
||||
{
|
||||
using var node = new RaftNode("N1");
|
||||
node.GroupName.ShouldBe("N1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RaftNode_group_uses_explicit_value_when_supplied()
|
||||
{
|
||||
using var node = new RaftNode("N1", group: "G1");
|
||||
node.GroupName.ShouldBe("G1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RaftNode_created_utc_is_set_on_construction()
|
||||
{
|
||||
var before = DateTime.UtcNow;
|
||||
using var node = new RaftNode("N1");
|
||||
var after = DateTime.UtcNow;
|
||||
|
||||
node.CreatedUtc.ShouldBeGreaterThanOrEqualTo(before);
|
||||
node.CreatedUtc.ShouldBeLessThanOrEqualTo(after);
|
||||
}
|
||||
}
|
||||
149
tests/NATS.Server.Tests/Raft/RaftNodeParityBatch2Tests.cs
Normal file
149
tests/NATS.Server.Tests/Raft/RaftNodeParityBatch2Tests.cs
Normal file
@@ -0,0 +1,149 @@
|
||||
using NATS.Server.Raft;
|
||||
|
||||
namespace NATS.Server.Tests.Raft;
|
||||
|
||||
public class RaftNodeParityBatch2Tests
|
||||
{
|
||||
private static RaftNode ElectSingleNodeLeader()
|
||||
{
|
||||
var node = new RaftNode("n1");
|
||||
node.ConfigureCluster([node]);
|
||||
node.StartElection(1);
|
||||
node.IsLeader.ShouldBeTrue();
|
||||
return node;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Leader_tracking_flags_update_on_election_and_heartbeat()
|
||||
{
|
||||
var node1 = new RaftNode("n1");
|
||||
var node2 = new RaftNode("n2");
|
||||
var node3 = new RaftNode("n3");
|
||||
|
||||
node1.ConfigureCluster([node1, node2, node3]);
|
||||
node2.ConfigureCluster([node1, node2, node3]);
|
||||
node3.ConfigureCluster([node1, node2, node3]);
|
||||
|
||||
node1.StartElection(3);
|
||||
node1.ReceiveVote(node2.GrantVote(node1.Term, node1.Id), 3);
|
||||
|
||||
node1.IsLeader.ShouldBeTrue();
|
||||
node1.GroupLeader.ShouldBe("n1");
|
||||
node1.Leaderless.ShouldBeFalse();
|
||||
node1.HadPreviousLeader.ShouldBeTrue();
|
||||
node1.LeaderSince.ShouldNotBeNull();
|
||||
|
||||
node2.ReceiveHeartbeat(node1.Term, fromPeerId: "n1");
|
||||
node2.IsLeader.ShouldBeFalse();
|
||||
node2.GroupLeader.ShouldBe("n1");
|
||||
node2.Leaderless.ShouldBeFalse();
|
||||
node2.HadPreviousLeader.ShouldBeTrue();
|
||||
node2.LeaderSince.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stepdown_clears_group_leader_and_leader_since()
|
||||
{
|
||||
using var leader = ElectSingleNodeLeader();
|
||||
leader.GroupLeader.ShouldBe("n1");
|
||||
leader.LeaderSince.ShouldNotBeNull();
|
||||
|
||||
leader.RequestStepDown();
|
||||
|
||||
leader.Leaderless.ShouldBeTrue();
|
||||
leader.GroupLeader.ShouldBe(RaftNode.NoLeader);
|
||||
leader.LeaderSince.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Observer_mode_can_be_toggled()
|
||||
{
|
||||
using var node = new RaftNode("n1");
|
||||
node.IsObserver.ShouldBeFalse();
|
||||
|
||||
node.SetObserver(true);
|
||||
node.IsObserver.ShouldBeTrue();
|
||||
|
||||
node.SetObserver(false);
|
||||
node.IsObserver.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cluster_size_adjustments_enforce_boot_and_leader_rules()
|
||||
{
|
||||
using var node = new RaftNode("n1");
|
||||
node.ClusterSize().ShouldBe(1);
|
||||
|
||||
node.AdjustBootClusterSize(1).ShouldBeTrue();
|
||||
node.ClusterSize().ShouldBe(2); // floor is 2
|
||||
|
||||
node.ConfigureCluster([node]);
|
||||
node.StartElection(1);
|
||||
node.IsLeader.ShouldBeTrue();
|
||||
|
||||
node.AdjustClusterSize(5).ShouldBeTrue();
|
||||
node.ClusterSize().ShouldBe(5);
|
||||
node.AdjustBootClusterSize(7).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Progress_size_and_applied_accessors_report_expected_values()
|
||||
{
|
||||
using var leader = ElectSingleNodeLeader();
|
||||
await leader.ProposeAsync("abc", CancellationToken.None);
|
||||
await leader.ProposeAsync("de", CancellationToken.None);
|
||||
|
||||
var progress = leader.Progress();
|
||||
progress.Index.ShouldBe(2);
|
||||
progress.Commit.ShouldBe(2);
|
||||
progress.Applied.ShouldBe(2);
|
||||
|
||||
var size = leader.Size();
|
||||
size.Entries.ShouldBe(2);
|
||||
size.Bytes.ShouldBe(5);
|
||||
|
||||
var applied = leader.Applied(1);
|
||||
applied.Entries.ShouldBe(1);
|
||||
applied.Bytes.ShouldBe(3);
|
||||
leader.ProcessedIndex.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Campaign_timeout_randomization_and_defaults_match_go_constants()
|
||||
{
|
||||
using var node = new RaftNode("n1");
|
||||
for (var i = 0; i < 20; i++)
|
||||
{
|
||||
var timeout = node.RandomizedCampaignTimeout();
|
||||
timeout.ShouldBeGreaterThanOrEqualTo(RaftNode.MinCampaignTimeoutDefault);
|
||||
timeout.ShouldBeLessThan(RaftNode.MaxCampaignTimeoutDefault);
|
||||
}
|
||||
|
||||
RaftNode.HbIntervalDefault.ShouldBe(TimeSpan.FromSeconds(1));
|
||||
RaftNode.LostQuorumIntervalDefault.ShouldBe(TimeSpan.FromSeconds(10));
|
||||
RaftNode.ObserverModeIntervalDefault.ShouldBe(TimeSpan.FromHours(48));
|
||||
RaftNode.PeerRemoveTimeoutDefault.ShouldBe(TimeSpan.FromMinutes(5));
|
||||
RaftNode.NoLeader.ShouldBe(string.Empty);
|
||||
RaftNode.NoVote.ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stop_wait_for_stop_and_delete_set_lifecycle_state()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"raft-node-delete-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(path);
|
||||
File.WriteAllText(Path.Combine(path, "marker.txt"), "x");
|
||||
|
||||
using var node = new RaftNode("n1", persistDirectory: path);
|
||||
node.IsDeleted.ShouldBeFalse();
|
||||
|
||||
node.Stop();
|
||||
node.WaitForStop();
|
||||
node.IsDeleted.ShouldBeFalse();
|
||||
Directory.Exists(path).ShouldBeTrue();
|
||||
|
||||
node.Delete();
|
||||
node.IsDeleted.ShouldBeTrue();
|
||||
Directory.Exists(path).ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
79
tests/NATS.Server.Tests/Raft/RaftParityBatch3Tests.cs
Normal file
79
tests/NATS.Server.Tests/Raft/RaftParityBatch3Tests.cs
Normal file
@@ -0,0 +1,79 @@
|
||||
using NATS.Server.Raft;
|
||||
|
||||
namespace NATS.Server.Tests.Raft;
|
||||
|
||||
public class RaftParityBatch3Tests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ProposeMulti_proposes_entries_in_order()
|
||||
{
|
||||
using var leader = ElectSingleNodeLeader();
|
||||
|
||||
var indexes = await leader.ProposeMultiAsync(["cmd-1", "cmd-2", "cmd-3"], CancellationToken.None);
|
||||
|
||||
indexes.Count.ShouldBe(3);
|
||||
indexes[0].ShouldBe(1);
|
||||
indexes[1].ShouldBe(2);
|
||||
indexes[2].ShouldBe(3);
|
||||
leader.Log.Entries.Count.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PeerState_tracks_lag_and_current_flags()
|
||||
{
|
||||
var peer = new RaftPeerState
|
||||
{
|
||||
PeerId = "n2",
|
||||
NextIndex = 10,
|
||||
MatchIndex = 7,
|
||||
LastContact = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
peer.RecalculateLag();
|
||||
peer.RefreshCurrent(TimeSpan.FromSeconds(1));
|
||||
|
||||
peer.Lag.ShouldBe(2);
|
||||
peer.Current.ShouldBeTrue();
|
||||
|
||||
peer.LastContact = DateTime.UtcNow - TimeSpan.FromSeconds(5);
|
||||
peer.RefreshCurrent(TimeSpan.FromSeconds(1));
|
||||
peer.Current.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CommittedEntry_contains_index_and_entries()
|
||||
{
|
||||
var entries = new[]
|
||||
{
|
||||
new RaftLogEntry(42, 3, "set x"),
|
||||
new RaftLogEntry(43, 3, "set y"),
|
||||
};
|
||||
|
||||
var committed = new CommittedEntry(43, entries);
|
||||
|
||||
committed.Index.ShouldBe(43);
|
||||
committed.Entries.Count.ShouldBe(2);
|
||||
committed.Entries[0].Command.ShouldBe("set x");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RaftEntry_roundtrips_to_wire_shape()
|
||||
{
|
||||
var entry = new RaftEntry(RaftEntryType.AddPeer, new byte[] { 1, 2, 3 });
|
||||
|
||||
var wire = entry.ToWire();
|
||||
var decoded = RaftEntry.FromWire(wire);
|
||||
|
||||
decoded.Type.ShouldBe(RaftEntryType.AddPeer);
|
||||
decoded.Data.ShouldBe(new byte[] { 1, 2, 3 });
|
||||
}
|
||||
|
||||
private static RaftNode ElectSingleNodeLeader()
|
||||
{
|
||||
var node = new RaftNode("n1");
|
||||
node.ConfigureCluster([node]);
|
||||
node.StartElection(1);
|
||||
node.IsLeader.ShouldBeTrue();
|
||||
return node;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using NATS.Server.Routes;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Tests.Routes;
|
||||
|
||||
public class RouteBatchProtoParityBatch3Tests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SendRouteSubProtosAsync_writes_batched_rs_plus_frames()
|
||||
{
|
||||
var (connection, peer) = CreateRoutePair();
|
||||
try
|
||||
{
|
||||
await connection.SendRouteSubProtosAsync(
|
||||
[
|
||||
new RemoteSubscription("orders.*", null, "r1", Account: "A"),
|
||||
new RemoteSubscription("orders.q", "workers", "r1", Account: "A", QueueWeight: 2),
|
||||
],
|
||||
CancellationToken.None);
|
||||
|
||||
var data = ReadFromPeer(peer);
|
||||
data.ShouldContain("RS+ A orders.*");
|
||||
data.ShouldContain("RS+ A orders.q workers 2");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await connection.DisposeAsync();
|
||||
peer.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendRouteUnSubProtosAsync_writes_batched_rs_minus_frames()
|
||||
{
|
||||
var (connection, peer) = CreateRoutePair();
|
||||
try
|
||||
{
|
||||
await connection.SendRouteUnSubProtosAsync(
|
||||
[
|
||||
new RemoteSubscription("orders.*", null, "r1", Account: "A"),
|
||||
new RemoteSubscription("orders.q", "workers", "r1", Account: "A"),
|
||||
],
|
||||
CancellationToken.None);
|
||||
|
||||
var data = ReadFromPeer(peer);
|
||||
data.ShouldContain("RS- A orders.*");
|
||||
data.ShouldContain("RS- A orders.q workers");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await connection.DisposeAsync();
|
||||
peer.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendRouteSubOrUnSubProtosAsync_skips_empty_lines_and_flushes_once()
|
||||
{
|
||||
var (connection, peer) = CreateRoutePair();
|
||||
try
|
||||
{
|
||||
await connection.SendRouteSubOrUnSubProtosAsync(
|
||||
["RS+ A foo.bar", "", " ", "RS- A foo.bar"],
|
||||
CancellationToken.None);
|
||||
|
||||
var data = ReadFromPeer(peer);
|
||||
data.ShouldContain("RS+ A foo.bar");
|
||||
data.ShouldContain("RS- A foo.bar");
|
||||
data.ShouldNotContain("\r\n\r\n");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await connection.DisposeAsync();
|
||||
peer.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static (RouteConnection Route, Socket Peer) CreateRoutePair()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var endpoint = (IPEndPoint)listener.LocalEndpoint;
|
||||
|
||||
var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
client.Connect(endpoint);
|
||||
|
||||
var server = listener.AcceptSocket();
|
||||
listener.Stop();
|
||||
|
||||
return (new RouteConnection(client), server);
|
||||
}
|
||||
|
||||
private static string ReadFromPeer(Socket peer)
|
||||
{
|
||||
peer.ReceiveTimeout = 2_000;
|
||||
var buffer = new byte[4096];
|
||||
var read = peer.Receive(buffer);
|
||||
return Encoding.ASCII.GetString(buffer, 0, read);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server;
|
||||
|
||||
namespace NATS.Server.Tests.Routes;
|
||||
|
||||
public class RouteInfoBroadcastParityBatch4Tests
|
||||
{
|
||||
[Fact]
|
||||
public async Task UpdateServerINFOAndSendINFOToClients_broadcasts_INFO_to_connected_clients()
|
||||
{
|
||||
var port = GetFreePort();
|
||||
using var server = new NatsServer(new NatsOptions { Host = "127.0.0.1", Port = port }, NullLoggerFactory.Instance);
|
||||
using var cts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await socket.ConnectAsync(IPAddress.Loopback, port);
|
||||
|
||||
_ = await ReadLineAsync(socket, CancellationToken.None); // initial INFO
|
||||
await socket.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\nPING\r\n"), SocketFlags.None);
|
||||
_ = await ReadUntilContainsAsync(socket, "PONG", CancellationToken.None);
|
||||
|
||||
server.UpdateServerINFOAndSendINFOToClients();
|
||||
|
||||
var info = await ReadLineAsync(socket, CancellationToken.None);
|
||||
info.ShouldStartWith("INFO ");
|
||||
|
||||
await server.ShutdownAsync();
|
||||
}
|
||||
|
||||
private static async Task<string> ReadUntilContainsAsync(Socket socket, string token, CancellationToken ct)
|
||||
{
|
||||
var end = DateTime.UtcNow.AddSeconds(3);
|
||||
var builder = new StringBuilder();
|
||||
while (DateTime.UtcNow < end)
|
||||
{
|
||||
var line = await ReadLineAsync(socket, ct);
|
||||
if (line.Length == 0)
|
||||
continue;
|
||||
|
||||
builder.AppendLine(line);
|
||||
if (builder.ToString().Contains(token, StringComparison.Ordinal))
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static async Task<string> ReadLineAsync(Socket socket, CancellationToken ct)
|
||||
{
|
||||
var buffer = new List<byte>(256);
|
||||
var one = new byte[1];
|
||||
while (true)
|
||||
{
|
||||
var n = await socket.ReceiveAsync(one.AsMemory(0, 1), SocketFlags.None, ct);
|
||||
if (n == 0)
|
||||
break;
|
||||
if (one[0] == '\n')
|
||||
break;
|
||||
if (one[0] != '\r')
|
||||
buffer.Add(one[0]);
|
||||
}
|
||||
|
||||
return Encoding.ASCII.GetString([.. buffer]);
|
||||
}
|
||||
|
||||
private static int GetFreePort()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
try
|
||||
{
|
||||
return ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
}
|
||||
finally
|
||||
{
|
||||
listener.Stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
170
tests/NATS.Server.Tests/Routes/RouteParityHelpersBatch1Tests.cs
Normal file
170
tests/NATS.Server.Tests/Routes/RouteParityHelpersBatch1Tests.cs
Normal file
@@ -0,0 +1,170 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server.Configuration;
|
||||
using NATS.Server.Protocol;
|
||||
using NATS.Server.Routes;
|
||||
|
||||
namespace NATS.Server.Tests.Routes;
|
||||
|
||||
public class RouteParityHelpersBatch1Tests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildConnectInfoJson_includes_connectinfo_compat_fields()
|
||||
{
|
||||
var json = RouteConnection.BuildConnectInfoJson("S1", ["A"], "topo-v1");
|
||||
|
||||
json.ShouldContain("\"verbose\":false");
|
||||
json.ShouldContain("\"pedantic\":false");
|
||||
json.ShouldContain("\"echo\":false");
|
||||
json.ShouldContain("\"tls_required\":false");
|
||||
json.ShouldContain("\"headers\":true");
|
||||
json.ShouldContain("\"name\":\"S1\"");
|
||||
json.ShouldContain("\"cluster\":\"\"");
|
||||
json.ShouldContain("\"dynamic\":false");
|
||||
json.ShouldContain("\"lnoc\":false");
|
||||
json.ShouldContain("\"lnocu\":false");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasThisRouteConfigured_matches_explicit_routes_with_scheme_normalization()
|
||||
{
|
||||
var manager = CreateManager(new ClusterOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Routes = ["127.0.0.1:7222"],
|
||||
});
|
||||
|
||||
manager.HasThisRouteConfigured("127.0.0.1:7222").ShouldBeTrue();
|
||||
manager.HasThisRouteConfigured("nats-route://127.0.0.1:7222").ShouldBeTrue();
|
||||
manager.HasThisRouteConfigured("nats://127.0.0.1:7222").ShouldBeTrue();
|
||||
manager.HasThisRouteConfigured("127.0.0.1:7999").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessImplicitRoute_skips_configured_routes_and_tracks_new_routes()
|
||||
{
|
||||
var manager = CreateManager(new ClusterOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Routes = ["127.0.0.1:7222"],
|
||||
});
|
||||
|
||||
var serverInfo = new ServerInfo
|
||||
{
|
||||
ServerId = "S2",
|
||||
ServerName = "S2",
|
||||
Version = NatsProtocol.Version,
|
||||
Host = "127.0.0.1",
|
||||
Port = 7222,
|
||||
ConnectUrls = ["127.0.0.1:7222", "nats-route://127.0.0.1:7444"],
|
||||
};
|
||||
|
||||
manager.ProcessImplicitRoute(serverInfo);
|
||||
|
||||
manager.DiscoveredRoutes.ShouldNotContain("127.0.0.1:7222");
|
||||
manager.DiscoveredRoutes.ShouldContain("nats-route://127.0.0.1:7444");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RouteStillValid_checks_configured_and_discovered_routes()
|
||||
{
|
||||
var manager = CreateManager(new ClusterOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Routes = ["127.0.0.1:7222"],
|
||||
});
|
||||
|
||||
manager.RouteStillValid("nats://127.0.0.1:7222").ShouldBeTrue();
|
||||
manager.RouteStillValid("127.0.0.1:7555").ShouldBeFalse();
|
||||
|
||||
manager.ProcessImplicitRoute(new ServerInfo
|
||||
{
|
||||
ServerId = "S2",
|
||||
ServerName = "S2",
|
||||
Version = NatsProtocol.Version,
|
||||
Host = "127.0.0.1",
|
||||
Port = 7444,
|
||||
ConnectUrls = ["127.0.0.1:7444"],
|
||||
});
|
||||
|
||||
manager.RouteStillValid("nats-route://127.0.0.1:7444").ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Solicited_route_helpers_upgrade_and_query_status()
|
||||
{
|
||||
var manager = CreateManager();
|
||||
await using var connection = MakeRouteConnection();
|
||||
|
||||
manager.RegisterRoute("S2", connection);
|
||||
manager.HasSolicitedRoute("S2").ShouldBeFalse();
|
||||
|
||||
manager.UpgradeRouteToSolicited("S2").ShouldBeTrue();
|
||||
connection.IsSolicitedRoute().ShouldBeTrue();
|
||||
manager.HasSolicitedRoute("S2").ShouldBeTrue();
|
||||
manager.IsDuplicateServerName("S2").ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveRoute_cleans_hash_and_account_route_indexes()
|
||||
{
|
||||
var manager = CreateManager();
|
||||
var connection = MakeRouteConnection();
|
||||
|
||||
manager.RegisterRoute("S2", connection);
|
||||
manager.RegisterRouteByHash("S2", connection);
|
||||
manager.RegisterAccountRoute("A", connection);
|
||||
|
||||
manager.HashedRouteCount.ShouldBe(1);
|
||||
manager.DedicatedRouteCount.ShouldBe(1);
|
||||
|
||||
manager.RemoveRoute("S2").ShouldBeTrue();
|
||||
manager.HashedRouteCount.ShouldBe(0);
|
||||
manager.DedicatedRouteCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryParseRemoteUnsub_parses_rs_minus_and_ls_minus()
|
||||
{
|
||||
RouteConnection.TryParseRemoteUnsub("RS- ACCT_A foo.bar q1", out var account1, out var subject1, out var queue1).ShouldBeTrue();
|
||||
account1.ShouldBe("ACCT_A");
|
||||
subject1.ShouldBe("foo.bar");
|
||||
queue1.ShouldBe("q1");
|
||||
|
||||
RouteConnection.TryParseRemoteUnsub("LS- ACCT_B foo.>", out var account2, out var subject2, out var queue2).ShouldBeTrue();
|
||||
account2.ShouldBe("ACCT_B");
|
||||
subject2.ShouldBe("foo.>");
|
||||
queue2.ShouldBeNull();
|
||||
|
||||
RouteConnection.TryParseRemoteUnsub("RS+ ACCT_A foo.bar", out _, out _, out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
private static RouteManager CreateManager(ClusterOptions? options = null)
|
||||
=> new(
|
||||
options ?? new ClusterOptions { Host = "127.0.0.1", Port = 0 },
|
||||
new ServerStats(),
|
||||
"S1",
|
||||
_ => { },
|
||||
_ => { },
|
||||
NullLogger<RouteManager>.Instance);
|
||||
|
||||
private static RouteConnection MakeRouteConnection()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var endpoint = (IPEndPoint)listener.LocalEndpoint;
|
||||
|
||||
var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
client.Connect(endpoint);
|
||||
|
||||
var server = listener.AcceptSocket();
|
||||
server.Dispose();
|
||||
listener.Stop();
|
||||
|
||||
return new RouteConnection(client);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server.Configuration;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Tests.Routes;
|
||||
|
||||
public class RouteRemoteSubCleanupParityBatch2Tests
|
||||
{
|
||||
[Fact]
|
||||
public void Routed_sub_key_helpers_parse_account_and_queue_fields()
|
||||
{
|
||||
var key = SubList.BuildRoutedSubKey("R1", "A", "orders.*", "q1");
|
||||
|
||||
SubList.GetAccNameFromRoutedSubKey(key).ShouldBe("A");
|
||||
|
||||
var info = SubList.GetRoutedSubKeyInfo(key);
|
||||
info.ShouldNotBeNull();
|
||||
info.Value.RouteId.ShouldBe("R1");
|
||||
info.Value.Account.ShouldBe("A");
|
||||
info.Value.Subject.ShouldBe("orders.*");
|
||||
info.Value.Queue.ShouldBe("q1");
|
||||
|
||||
SubList.GetRoutedSubKeyInfo("invalid").ShouldBeNull();
|
||||
SubList.GetAccNameFromRoutedSubKey("invalid").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Remove_remote_subs_methods_only_remove_matching_route_or_account()
|
||||
{
|
||||
using var sl = new SubList();
|
||||
sl.ApplyRemoteSub(new RemoteSubscription("orders.*", null, "r1", "A"));
|
||||
sl.ApplyRemoteSub(new RemoteSubscription("orders.*", null, "r1", "B"));
|
||||
sl.ApplyRemoteSub(new RemoteSubscription("orders.*", null, "r2", "A"));
|
||||
|
||||
sl.HasRemoteInterest("A", "orders.created").ShouldBeTrue();
|
||||
sl.HasRemoteInterest("B", "orders.created").ShouldBeTrue();
|
||||
|
||||
sl.RemoveRemoteSubsForAccount("r1", "A").ShouldBe(1);
|
||||
sl.HasRemoteInterest("A", "orders.created").ShouldBeTrue(); // r2 still present
|
||||
sl.HasRemoteInterest("B", "orders.created").ShouldBeTrue();
|
||||
|
||||
sl.RemoveRemoteSubs("r2").ShouldBe(1);
|
||||
sl.HasRemoteInterest("A", "orders.created").ShouldBeFalse();
|
||||
sl.HasRemoteInterest("B", "orders.created").ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Route_disconnect_cleans_remote_interest_without_explicit_rs_minus()
|
||||
{
|
||||
var opts = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Cluster = new ClusterOptions
|
||||
{
|
||||
Name = Guid.NewGuid().ToString("N"),
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
PoolSize = 1,
|
||||
},
|
||||
};
|
||||
|
||||
var server = new NatsServer(opts, NullLoggerFactory.Instance);
|
||||
using var serverCts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(serverCts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
var cluster = server.ClusterListen!;
|
||||
var sep = cluster.LastIndexOf(':');
|
||||
var host = cluster[..sep];
|
||||
var port = int.Parse(cluster[(sep + 1)..]);
|
||||
|
||||
using var remote = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(8));
|
||||
await remote.ConnectAsync(IPAddress.Parse(host), port, timeout.Token);
|
||||
|
||||
await WriteLineAsync(remote, "ROUTE REMOTE1", timeout.Token);
|
||||
var response = await ReadLineAsync(remote, timeout.Token);
|
||||
response.ShouldStartWith("ROUTE ");
|
||||
|
||||
await WriteLineAsync(remote, "RS+ $G route.cleanup.test", timeout.Token);
|
||||
await WaitForCondition(() => server.HasRemoteInterest("route.cleanup.test"), 5000);
|
||||
|
||||
remote.Dispose();
|
||||
|
||||
await WaitForCondition(() => !server.HasRemoteInterest("route.cleanup.test"), 10000);
|
||||
server.HasRemoteInterest("route.cleanup.test").ShouldBeFalse();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await serverCts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WaitForCondition(Func<bool> predicate, int timeoutMs)
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(timeoutMs);
|
||||
while (!timeout.IsCancellationRequested)
|
||||
{
|
||||
if (predicate())
|
||||
return;
|
||||
|
||||
await Task.Yield();
|
||||
}
|
||||
|
||||
throw new TimeoutException("Condition not met.");
|
||||
}
|
||||
|
||||
private static async Task WriteLineAsync(Socket socket, string line, CancellationToken ct)
|
||||
{
|
||||
var data = Encoding.ASCII.GetBytes($"{line}\r\n");
|
||||
await socket.SendAsync(data, ct);
|
||||
}
|
||||
|
||||
private static async Task<string> ReadLineAsync(Socket socket, CancellationToken ct)
|
||||
{
|
||||
var bytes = new List<byte>(64);
|
||||
var one = new byte[1];
|
||||
while (true)
|
||||
{
|
||||
var read = await socket.ReceiveAsync(one, SocketFlags.None, ct);
|
||||
if (read == 0)
|
||||
throw new IOException("Socket closed while reading line");
|
||||
|
||||
if (one[0] == (byte)'\n')
|
||||
break;
|
||||
if (one[0] != (byte)'\r')
|
||||
bytes.Add(one[0]);
|
||||
}
|
||||
|
||||
return Encoding.ASCII.GetString([.. bytes]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Tests.Server;
|
||||
|
||||
public class CoreServerClientAccessorsParityBatch2Tests
|
||||
{
|
||||
[Fact]
|
||||
public void Client_protocol_constants_match_go_values()
|
||||
{
|
||||
ClientProtocolVersion.ClientProtoZero.ShouldBe(0);
|
||||
ClientProtocolVersion.ClientProtoInfo.ShouldBe(1);
|
||||
|
||||
((int)ClientConnectionType.NonClient).ShouldBe(0);
|
||||
((int)ClientConnectionType.Nats).ShouldBe(1);
|
||||
((int)ClientConnectionType.Mqtt).ShouldBe(2);
|
||||
((int)ClientConnectionType.WebSocket).ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NatsClient_getters_and_client_type_behave_as_expected()
|
||||
{
|
||||
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
using var stream = new MemoryStream();
|
||||
|
||||
var opts = new NatsOptions();
|
||||
var info = new ServerInfo
|
||||
{
|
||||
ServerId = "srv1",
|
||||
ServerName = "srv",
|
||||
Version = "1.0.0",
|
||||
Host = "127.0.0.1",
|
||||
Port = 4222,
|
||||
};
|
||||
var auth = AuthService.Build(opts);
|
||||
var nonce = new byte[] { 1, 2, 3 };
|
||||
var stats = new ServerStats();
|
||||
|
||||
using var client = new NatsClient(
|
||||
id: 42,
|
||||
stream: stream,
|
||||
socket: socket,
|
||||
options: opts,
|
||||
serverInfo: info,
|
||||
authService: auth,
|
||||
nonce: nonce,
|
||||
logger: NullLogger.Instance,
|
||||
serverStats: stats);
|
||||
|
||||
client.ClientType().ShouldBe(ClientConnectionType.Nats);
|
||||
client.IsWebSocket = true;
|
||||
client.ClientType().ShouldBe(ClientConnectionType.WebSocket);
|
||||
client.IsWebSocket = false;
|
||||
client.IsMqtt = true;
|
||||
client.ClientType().ShouldBe(ClientConnectionType.Mqtt);
|
||||
client.GetName().ShouldBe(string.Empty);
|
||||
client.GetNonce().ShouldBe(nonce);
|
||||
client.ToString().ShouldContain("cid=42");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NatsClient_client_type_non_client_when_kind_is_not_client()
|
||||
{
|
||||
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
using var stream = new MemoryStream();
|
||||
|
||||
var opts = new NatsOptions();
|
||||
var info = new ServerInfo
|
||||
{
|
||||
ServerId = "srv1",
|
||||
ServerName = "srv",
|
||||
Version = "1.0.0",
|
||||
Host = "127.0.0.1",
|
||||
Port = 4222,
|
||||
};
|
||||
var auth = AuthService.Build(opts);
|
||||
var stats = new ServerStats();
|
||||
|
||||
using var routeClient = new NatsClient(
|
||||
id: 7,
|
||||
stream: stream,
|
||||
socket: socket,
|
||||
options: opts,
|
||||
serverInfo: info,
|
||||
authService: auth,
|
||||
nonce: null,
|
||||
logger: NullLogger.Instance,
|
||||
serverStats: stats,
|
||||
kind: ClientKind.Router);
|
||||
|
||||
routeClient.ClientType().ShouldBe(ClientConnectionType.NonClient);
|
||||
}
|
||||
}
|
||||
282
tests/NATS.Server.Tests/Server/CoreServerGapParityTests.cs
Normal file
282
tests/NATS.Server.Tests/Server/CoreServerGapParityTests.cs
Normal file
@@ -0,0 +1,282 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server;
|
||||
using NATS.Server.Configuration;
|
||||
|
||||
namespace NATS.Server.Tests.Server;
|
||||
|
||||
public class CoreServerGapParityTests
|
||||
{
|
||||
[Fact]
|
||||
public void ClientURL_uses_advertise_when_present()
|
||||
{
|
||||
using var server = new NatsServer(
|
||||
new NatsOptions { Host = "0.0.0.0", Port = 4222, ClientAdvertise = "demo.example.net:4333" },
|
||||
NullLoggerFactory.Instance);
|
||||
|
||||
server.ClientURL().ShouldBe("nats://demo.example.net:4333");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClientURL_uses_loopback_for_wildcard_host()
|
||||
{
|
||||
using var server = new NatsServer(
|
||||
new NatsOptions { Host = "0.0.0.0", Port = 4222 },
|
||||
NullLoggerFactory.Instance);
|
||||
|
||||
server.ClientURL().ShouldBe("nats://127.0.0.1:4222");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WebsocketURL_uses_default_host_port_when_enabled()
|
||||
{
|
||||
using var server = new NatsServer(
|
||||
new NatsOptions
|
||||
{
|
||||
WebSocket = new WebSocketOptions
|
||||
{
|
||||
Host = "0.0.0.0",
|
||||
Port = 8080,
|
||||
NoTls = true,
|
||||
},
|
||||
},
|
||||
NullLoggerFactory.Instance);
|
||||
|
||||
server.WebsocketURL().ShouldBe("ws://127.0.0.1:8080");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WebsocketURL_returns_null_when_disabled()
|
||||
{
|
||||
using var server = new NatsServer(new NatsOptions(), NullLoggerFactory.Instance);
|
||||
|
||||
server.WebsocketURL().ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Account_count_methods_reflect_loaded_and_active_accounts()
|
||||
{
|
||||
using var server = new NatsServer(new NatsOptions(), NullLoggerFactory.Instance);
|
||||
|
||||
server.NumLoadedAccounts().ShouldBe(2); // $G + $SYS
|
||||
server.NumActiveAccounts().ShouldBe(0);
|
||||
|
||||
var app = server.GetOrCreateAccount("APP");
|
||||
server.NumLoadedAccounts().ShouldBe(3);
|
||||
|
||||
app.AddClient(42);
|
||||
server.NumActiveAccounts().ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Address_and_counter_methods_are_derived_from_options_and_stats()
|
||||
{
|
||||
using var server = new NatsServer(
|
||||
new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 4222,
|
||||
MonitorHost = "127.0.0.1",
|
||||
MonitorPort = 8222,
|
||||
ProfPort = 6060,
|
||||
},
|
||||
NullLoggerFactory.Instance);
|
||||
|
||||
server.Stats.Routes = 2;
|
||||
server.Stats.Gateways = 1;
|
||||
server.Stats.Leafs = 3;
|
||||
|
||||
server.Addr().ShouldBe("127.0.0.1:4222");
|
||||
server.MonitorAddr().ShouldBe("127.0.0.1:8222");
|
||||
server.ProfilerAddr().ShouldBe("127.0.0.1:6060");
|
||||
server.NumRoutes().ShouldBe(2);
|
||||
server.NumLeafNodes().ShouldBe(3);
|
||||
server.NumRemotes().ShouldBe(6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToString_includes_identity_and_address()
|
||||
{
|
||||
using var server = new NatsServer(
|
||||
new NatsOptions { ServerName = "test-node", Host = "127.0.0.1", Port = 4222 },
|
||||
NullLoggerFactory.Instance);
|
||||
|
||||
var value = server.ToString();
|
||||
|
||||
value.ShouldContain("NatsServer(");
|
||||
value.ShouldContain("Name=test-node");
|
||||
value.ShouldContain("Addr=127.0.0.1:4222");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PortsInfo_returns_configured_listen_endpoints()
|
||||
{
|
||||
using var server = new NatsServer(
|
||||
new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 4222,
|
||||
MonitorHost = "127.0.0.1",
|
||||
MonitorPort = 8222,
|
||||
ProfPort = 6060,
|
||||
WebSocket = new WebSocketOptions { Host = "127.0.0.1", Port = 8443 },
|
||||
Cluster = new ClusterOptions { Host = "127.0.0.1", Port = 6222 },
|
||||
LeafNode = new LeafNodeOptions { Host = "127.0.0.1", Port = 7422 },
|
||||
},
|
||||
NullLoggerFactory.Instance);
|
||||
|
||||
var ports = server.PortsInfo();
|
||||
|
||||
ports.Nats.ShouldContain("127.0.0.1:4222");
|
||||
ports.Monitoring.ShouldContain("127.0.0.1:8222");
|
||||
ports.Profile.ShouldContain("127.0.0.1:6060");
|
||||
ports.WebSocket.ShouldContain("127.0.0.1:8443");
|
||||
ports.Cluster.ShouldContain("127.0.0.1:6222");
|
||||
ports.LeafNodes.ShouldContain("127.0.0.1:7422");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Profiler_and_peer_accessors_have_parity_surface()
|
||||
{
|
||||
using var server = new NatsServer(
|
||||
new NatsOptions { Port = 4222, ProfPort = 6060 },
|
||||
NullLoggerFactory.Instance);
|
||||
|
||||
server.StartProfiler().ShouldBeTrue();
|
||||
server.ActivePeers().ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Connect_urls_helpers_include_non_wildcard_and_cache_refresh()
|
||||
{
|
||||
using var server = new NatsServer(
|
||||
new NatsOptions { Host = "127.0.0.1", Port = 4222 },
|
||||
NullLoggerFactory.Instance);
|
||||
|
||||
var urls = server.GetConnectURLs();
|
||||
urls.ShouldContain("nats://127.0.0.1:4222");
|
||||
|
||||
server.UpdateServerINFOAndSendINFOToClients();
|
||||
var info = Encoding.ASCII.GetString(server.CachedInfoLine);
|
||||
info.ShouldContain("\"connect_urls\":[\"nats://127.0.0.1:4222\"]");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisconnectClientByID_closes_connected_client()
|
||||
{
|
||||
var port = GetFreePort();
|
||||
using var server = new NatsServer(new NatsOptions { Host = "127.0.0.1", Port = port }, NullLoggerFactory.Instance);
|
||||
using var cts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
using var socket = await ConnectAndHandshakeAsync(port);
|
||||
await WaitUntilAsync(() => server.ClientCount == 1);
|
||||
var clientId = server.GetClients().Single().Id;
|
||||
|
||||
server.DisconnectClientByID(clientId).ShouldBeTrue();
|
||||
await WaitUntilAsync(() => server.ClientCount == 0);
|
||||
|
||||
await server.ShutdownAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LDMClientByID_closes_connected_client()
|
||||
{
|
||||
var port = GetFreePort();
|
||||
using var server = new NatsServer(new NatsOptions { Host = "127.0.0.1", Port = port }, NullLoggerFactory.Instance);
|
||||
using var cts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
using var socket = await ConnectAndHandshakeAsync(port);
|
||||
await WaitUntilAsync(() => server.ClientCount == 1);
|
||||
var clientId = server.GetClients().Single().Id;
|
||||
|
||||
server.LDMClientByID(clientId).ShouldBeTrue();
|
||||
await WaitUntilAsync(() => server.ClientCount == 0);
|
||||
|
||||
await server.ShutdownAsync();
|
||||
}
|
||||
|
||||
private static async Task<Socket> ConnectAndHandshakeAsync(int port)
|
||||
{
|
||||
var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await socket.ConnectAsync(IPAddress.Loopback, port);
|
||||
|
||||
_ = await ReadLineAsync(socket, CancellationToken.None); // INFO
|
||||
await socket.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\nPING\r\n"), SocketFlags.None);
|
||||
var pong = await ReadUntilContainsAsync(socket, "PONG", CancellationToken.None);
|
||||
pong.ShouldContain("PONG");
|
||||
|
||||
return socket;
|
||||
}
|
||||
|
||||
private static async Task<string> ReadLineAsync(Socket socket, CancellationToken ct)
|
||||
{
|
||||
var buffer = new List<byte>(256);
|
||||
var single = new byte[1];
|
||||
while (true)
|
||||
{
|
||||
var n = await socket.ReceiveAsync(single.AsMemory(0, 1), SocketFlags.None, ct);
|
||||
if (n == 0)
|
||||
break;
|
||||
|
||||
if (single[0] == '\n')
|
||||
break;
|
||||
|
||||
if (single[0] != '\r')
|
||||
buffer.Add(single[0]);
|
||||
}
|
||||
|
||||
return Encoding.ASCII.GetString([.. buffer]);
|
||||
}
|
||||
|
||||
private static async Task<string> ReadUntilContainsAsync(Socket socket, string token, CancellationToken ct)
|
||||
{
|
||||
var end = DateTime.UtcNow.AddSeconds(3);
|
||||
var builder = new StringBuilder();
|
||||
while (DateTime.UtcNow < end)
|
||||
{
|
||||
var line = await ReadLineAsync(socket, ct);
|
||||
if (line.Length == 0)
|
||||
continue;
|
||||
|
||||
builder.AppendLine(line);
|
||||
if (builder.ToString().Contains(token, StringComparison.Ordinal))
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static int GetFreePort()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
try
|
||||
{
|
||||
return ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
}
|
||||
finally
|
||||
{
|
||||
listener.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WaitUntilAsync(Func<bool> predicate)
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
while (!cts.IsCancellationRequested)
|
||||
{
|
||||
if (predicate())
|
||||
return;
|
||||
|
||||
await Task.Yield();
|
||||
}
|
||||
|
||||
throw new TimeoutException("Condition was not met in time.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using NATS.Server.Server;
|
||||
|
||||
namespace NATS.Server.Tests.Server;
|
||||
|
||||
public class CoreServerOptionsParityBatch3Tests
|
||||
{
|
||||
[Fact]
|
||||
public void Core_ports_and_compression_types_exist_with_expected_defaults()
|
||||
{
|
||||
var ports = new Ports
|
||||
{
|
||||
Nats = ["nats://127.0.0.1:4222"],
|
||||
Monitoring = ["http://127.0.0.1:8222"],
|
||||
};
|
||||
|
||||
ports.Nats.ShouldHaveSingleItem();
|
||||
ports.Monitoring.ShouldHaveSingleItem();
|
||||
|
||||
CompressionModes.Off.ShouldBe("off");
|
||||
CompressionModes.S2Auto.ShouldBe("s2_auto");
|
||||
|
||||
var opts = new CompressionOpts();
|
||||
opts.Mode.ShouldBe(CompressionModes.Off);
|
||||
opts.RTTThresholds.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoutesFromStr_parses_comma_delimited_routes()
|
||||
{
|
||||
var routes = NatsOptions.RoutesFromStr(" nats://a:6222, tls://b:7222 ");
|
||||
|
||||
routes.Count.ShouldBe(2);
|
||||
routes[0].ToString().ShouldBe("nats://a:6222/");
|
||||
routes[1].ToString().ShouldBe("tls://b:7222/");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clone_returns_deep_copy_for_common_collections()
|
||||
{
|
||||
var original = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 4222,
|
||||
Tags = new Dictionary<string, string> { ["a"] = "1" },
|
||||
SubjectMappings = new Dictionary<string, string> { ["foo.*"] = "bar.$1" },
|
||||
TlsPinnedCerts = ["abc"],
|
||||
};
|
||||
original.InCmdLine.Add("host");
|
||||
|
||||
var clone = original.Clone();
|
||||
|
||||
clone.ShouldNotBeSameAs(original);
|
||||
clone.Tags.ShouldNotBeSameAs(original.Tags);
|
||||
clone.SubjectMappings.ShouldNotBeSameAs(original.SubjectMappings);
|
||||
clone.TlsPinnedCerts.ShouldNotBeSameAs(original.TlsPinnedCerts);
|
||||
clone.InCmdLine.ShouldNotBeSameAs(original.InCmdLine);
|
||||
clone.InCmdLine.ShouldContain("host");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessConfigString_sets_config_digest_and_applies_values()
|
||||
{
|
||||
var opts = new NatsOptions();
|
||||
opts.ProcessConfigString("port: 4333");
|
||||
|
||||
opts.Port.ShouldBe(4333);
|
||||
opts.ConfigDigest().ShouldNotBeNullOrWhiteSpace();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoErrOnUnknownFields_toggle_is_available()
|
||||
{
|
||||
NatsOptions.NoErrOnUnknownFields(true);
|
||||
var ex = Record.Exception(() => new NatsOptions().ProcessConfigString("totally_unknown_field: 1"));
|
||||
ex.ShouldBeNull();
|
||||
NatsOptions.NoErrOnUnknownFields(false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Option_parity_types_exist()
|
||||
{
|
||||
var jsLimits = new JSLimitOpts
|
||||
{
|
||||
MaxRequestBatch = 10,
|
||||
MaxAckPending = 20,
|
||||
};
|
||||
jsLimits.MaxRequestBatch.ShouldBe(10);
|
||||
jsLimits.MaxAckPending.ShouldBe(20);
|
||||
|
||||
var callout = new AuthCallout
|
||||
{
|
||||
Issuer = "issuer",
|
||||
Account = "A",
|
||||
AllowedAccounts = ["A", "B"],
|
||||
};
|
||||
callout.Issuer.ShouldBe("issuer");
|
||||
callout.AllowedAccounts.ShouldContain("B");
|
||||
|
||||
var proxies = new ProxiesConfig
|
||||
{
|
||||
Trusted = [new ProxyConfig { Key = "k1" }],
|
||||
};
|
||||
proxies.Trusted.Count.ShouldBe(1);
|
||||
proxies.Trusted[0].Key.ShouldBe("k1");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Net.Sockets;
|
||||
using NATS.Server.Routes;
|
||||
using NATS.Server.Server;
|
||||
|
||||
namespace NATS.Server.Tests.Server;
|
||||
|
||||
public class UtilitiesAndRateCounterParityBatch1Tests
|
||||
{
|
||||
[Fact]
|
||||
public void ParseHostPort_uses_default_port_for_missing_zero_and_minus_one()
|
||||
{
|
||||
ServerUtilities.ParseHostPort("127.0.0.1", 4222).ShouldBe(("127.0.0.1", 4222));
|
||||
ServerUtilities.ParseHostPort("127.0.0.1:0", 4222).ShouldBe(("127.0.0.1", 4222));
|
||||
ServerUtilities.ParseHostPort("127.0.0.1:-1", 4222).ShouldBe(("127.0.0.1", 4222));
|
||||
ServerUtilities.ParseHostPort(":4333", 4222).ShouldBe(("", 4333));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RedactUrl_helpers_redact_password_for_single_and_list_inputs()
|
||||
{
|
||||
ServerUtilities.RedactUrlString("nats://foo:bar@example.com:4222")
|
||||
.ShouldBe("nats://foo:xxxxx@example.com:4222");
|
||||
ServerUtilities.RedactUrlString("nats://example.com:4222")
|
||||
.ShouldBe("nats://example.com:4222");
|
||||
|
||||
var redacted = ServerUtilities.RedactUrlList(
|
||||
["nats://a:b@one:4222", "nats://noauth:4223"]);
|
||||
redacted[0].ShouldBe("nats://a:xxxxx@one:4222");
|
||||
redacted[1].ShouldBe("nats://noauth:4223");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RateCounter_allow_and_count_blocked_match_go_behavior()
|
||||
{
|
||||
var rc = new RateCounter(2);
|
||||
|
||||
rc.Allow().ShouldBeTrue();
|
||||
rc.Allow().ShouldBeTrue();
|
||||
rc.Allow().ShouldBeFalse();
|
||||
rc.Allow().ShouldBeFalse();
|
||||
|
||||
rc.CountBlocked().ShouldBe((ulong)2);
|
||||
rc.CountBlocked().ShouldBe((ulong)0); // reset on read
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateRouteDialSocket_disables_keepalive()
|
||||
{
|
||||
using var socket = RouteManager.CreateRouteDialSocket();
|
||||
var keepAlive = Convert.ToInt32(socket.GetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive));
|
||||
keepAlive.ShouldBe(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using NATS.Server.Server;
|
||||
|
||||
namespace NATS.Server.Tests.Server;
|
||||
|
||||
public class UtilitiesErrorConstantsParityBatch2Tests
|
||||
{
|
||||
[Fact]
|
||||
public void Error_constants_match_go_error_literals_batch2()
|
||||
{
|
||||
ServerErrorConstants.ErrBadQualifier.ShouldBe("bad qualifier");
|
||||
ServerErrorConstants.ErrTooManyAccountConnections.ShouldBe("maximum account active connections exceeded");
|
||||
ServerErrorConstants.ErrTooManySubs.ShouldBe("maximum subscriptions exceeded");
|
||||
ServerErrorConstants.ErrTooManySubTokens.ShouldBe("subject has exceeded number of tokens limit");
|
||||
ServerErrorConstants.ErrReservedAccount.ShouldBe("reserved account");
|
||||
ServerErrorConstants.ErrMissingService.ShouldBe("service missing");
|
||||
ServerErrorConstants.ErrBadServiceType.ShouldBe("bad service response type");
|
||||
ServerErrorConstants.ErrBadSampling.ShouldBe("bad sampling percentage, should be 1-100");
|
||||
ServerErrorConstants.ErrAccountResolverUpdateTooSoon.ShouldBe("account resolver update too soon");
|
||||
ServerErrorConstants.ErrAccountResolverSameClaims.ShouldBe("account resolver no new claims");
|
||||
ServerErrorConstants.ErrStreamImportAuthorization.ShouldBe("stream import not authorized");
|
||||
ServerErrorConstants.ErrStreamImportBadPrefix.ShouldBe("stream import prefix can not contain wildcard tokens");
|
||||
ServerErrorConstants.ErrStreamImportDuplicate.ShouldBe("stream import already exists");
|
||||
ServerErrorConstants.ErrServiceImportAuthorization.ShouldBe("service import not authorized");
|
||||
ServerErrorConstants.ErrImportFormsCycle.ShouldBe("import forms a cycle");
|
||||
ServerErrorConstants.ErrCycleSearchDepth.ShouldBe("search cycle depth exhausted");
|
||||
ServerErrorConstants.ErrNoTransforms.ShouldBe("no matching transforms available");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Tests.Subscriptions;
|
||||
|
||||
public class SubListCtorAndNotificationParityTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_with_enableCache_false_disables_cache()
|
||||
{
|
||||
var subList = new SubList(enableCache: false);
|
||||
|
||||
subList.CacheEnabled().ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewSublistNoCache_factory_disables_cache()
|
||||
{
|
||||
var subList = SubList.NewSublistNoCache();
|
||||
|
||||
subList.CacheEnabled().ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterNotification_emits_true_on_first_interest_and_false_on_last_interest()
|
||||
{
|
||||
var subList = new SubList();
|
||||
var notifications = new List<bool>();
|
||||
subList.RegisterNotification(v => notifications.Add(v));
|
||||
|
||||
var sub = new Subscription { Subject = "foo", Sid = "1" };
|
||||
subList.Insert(sub);
|
||||
subList.Remove(sub);
|
||||
|
||||
notifications.ShouldBe([true, false]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubjectMatch_alias_helpers_match_existing_behavior()
|
||||
{
|
||||
SubjectMatch.SubjectHasWildcard("foo.*").ShouldBeTrue();
|
||||
SubjectMatch.SubjectHasWildcard("foo.bar").ShouldBeFalse();
|
||||
|
||||
SubjectMatch.IsValidLiteralSubject("foo.bar").ShouldBeTrue();
|
||||
SubjectMatch.IsValidLiteralSubject("foo.*").ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using NATS.Server;
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Protocol;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Tests.Subscriptions;
|
||||
|
||||
public class SubListParityBatch2Tests
|
||||
{
|
||||
[Fact]
|
||||
public void RegisterQueueNotification_tracks_first_and_last_exact_queue_interest()
|
||||
{
|
||||
var subList = new SubList();
|
||||
var notifications = new List<bool>();
|
||||
Action<bool> callback = hasInterest => notifications.Add(hasInterest);
|
||||
|
||||
subList.RegisterQueueNotification("foo.bar", "q", callback).ShouldBeTrue();
|
||||
notifications.ShouldBe([false]);
|
||||
|
||||
var sub1 = new Subscription { Subject = "foo.bar", Queue = "q", Sid = "1" };
|
||||
var sub2 = new Subscription { Subject = "foo.bar", Queue = "q", Sid = "2" };
|
||||
|
||||
subList.Insert(sub1);
|
||||
subList.Insert(sub2);
|
||||
notifications.ShouldBe([false, true]);
|
||||
|
||||
subList.Remove(sub1);
|
||||
notifications.ShouldBe([false, true]);
|
||||
|
||||
subList.Remove(sub2);
|
||||
notifications.ShouldBe([false, true, false]);
|
||||
|
||||
subList.ClearQueueNotification("foo.bar", "q", callback).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateRemoteQSub_updates_queue_weight_for_match_remote()
|
||||
{
|
||||
var subList = new SubList();
|
||||
var original = new RemoteSubscription("foo.bar", "q", "R1", Account: "A", QueueWeight: 1);
|
||||
subList.ApplyRemoteSub(original);
|
||||
subList.MatchRemote("A", "foo.bar").Count.ShouldBe(1);
|
||||
|
||||
subList.UpdateRemoteQSub(original with { QueueWeight = 3 });
|
||||
subList.MatchRemote("A", "foo.bar").Count.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubListStats_Add_aggregates_stats_like_go()
|
||||
{
|
||||
var stats = new SubListStats
|
||||
{
|
||||
NumSubs = 1,
|
||||
NumCache = 2,
|
||||
NumInserts = 3,
|
||||
NumRemoves = 4,
|
||||
NumMatches = 10,
|
||||
MaxFanout = 5,
|
||||
TotalFanout = 8,
|
||||
CacheEntries = 2,
|
||||
CacheHits = 6,
|
||||
};
|
||||
|
||||
stats.Add(new SubListStats
|
||||
{
|
||||
NumSubs = 2,
|
||||
NumCache = 3,
|
||||
NumInserts = 4,
|
||||
NumRemoves = 5,
|
||||
NumMatches = 30,
|
||||
MaxFanout = 9,
|
||||
TotalFanout = 12,
|
||||
CacheEntries = 3,
|
||||
CacheHits = 15,
|
||||
});
|
||||
|
||||
stats.NumSubs.ShouldBe((uint)3);
|
||||
stats.NumCache.ShouldBe((uint)5);
|
||||
stats.NumInserts.ShouldBe((ulong)7);
|
||||
stats.NumRemoves.ShouldBe((ulong)9);
|
||||
stats.NumMatches.ShouldBe((ulong)40);
|
||||
stats.MaxFanout.ShouldBe((uint)9);
|
||||
stats.AvgFanout.ShouldBe(4.0);
|
||||
stats.CacheHitRate.ShouldBe(0.525);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NumLevels_returns_max_trie_depth()
|
||||
{
|
||||
var subList = new SubList();
|
||||
subList.Insert(new Subscription { Subject = "foo.bar.baz", Sid = "1" });
|
||||
subList.Insert(new Subscription { Subject = "foo.bar", Sid = "2" });
|
||||
|
||||
subList.NumLevels().ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LocalSubs_filters_non_local_kinds_and_optionally_includes_leaf()
|
||||
{
|
||||
var subList = new SubList();
|
||||
subList.Insert(new Subscription { Subject = "foo.a", Sid = "1", Client = new TestClient(ClientKind.Client) });
|
||||
subList.Insert(new Subscription { Subject = "foo.b", Sid = "2", Client = new TestClient(ClientKind.Router) });
|
||||
subList.Insert(new Subscription { Subject = "foo.c", Sid = "3", Client = new TestClient(ClientKind.System) });
|
||||
subList.Insert(new Subscription { Subject = "foo.d", Sid = "4", Client = new TestClient(ClientKind.Leaf) });
|
||||
|
||||
var local = subList.LocalSubs(includeLeafHubs: false).Select(s => s.Sid).OrderBy(x => x).ToArray();
|
||||
local.ShouldBe(["1", "3"]);
|
||||
|
||||
var withLeaf = subList.LocalSubs(includeLeafHubs: true).Select(s => s.Sid).OrderBy(x => x).ToArray();
|
||||
withLeaf.ShouldBe(["1", "3", "4"]);
|
||||
}
|
||||
|
||||
private sealed class TestClient(ClientKind kind) : INatsClient
|
||||
{
|
||||
public ulong Id => 1;
|
||||
public ClientKind Kind => kind;
|
||||
public Account? Account => null;
|
||||
public ClientOptions? ClientOpts => null;
|
||||
public ClientPermissions? Permissions => null;
|
||||
public void SendMessage(string subject, string sid, string? replyTo, ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload)
|
||||
{
|
||||
}
|
||||
|
||||
public bool QueueOutbound(ReadOnlyMemory<byte> data) => true;
|
||||
|
||||
public void RemoveSubscription(string sid)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Tests.Subscriptions;
|
||||
|
||||
public class SubjectSubsetMatchParityBatch1Tests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("foo.bar", "foo.bar", true)]
|
||||
[InlineData("foo.bar", "foo.*", true)]
|
||||
[InlineData("foo.bar", "foo.>", true)]
|
||||
[InlineData("foo.bar", "*.*", true)]
|
||||
[InlineData("foo.bar", ">", true)]
|
||||
[InlineData("foo.bar", "foo.baz", false)]
|
||||
[InlineData("foo.bar.baz", "foo.*", false)]
|
||||
public void SubjectMatchesFilter_matches_go_subset_behavior(string subject, string filter, bool expected)
|
||||
{
|
||||
SubjectMatch.SubjectMatchesFilter(subject, filter).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubjectIsSubsetMatch_uses_subject_tokens_against_test_pattern()
|
||||
{
|
||||
SubjectMatch.SubjectIsSubsetMatch("foo.*", "foo.*").ShouldBeTrue();
|
||||
SubjectMatch.SubjectIsSubsetMatch("foo.*", "foo.bar").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsSubsetMatch_tokenizes_test_subject_and_delegates_to_tokenized_matcher()
|
||||
{
|
||||
SubjectMatch.IsSubsetMatch(["foo", "bar"], "foo.*").ShouldBeTrue();
|
||||
SubjectMatch.IsSubsetMatch(["foo", "bar"], "foo.baz").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsSubsetMatchTokenized_handles_fwc_and_rejects_empty_tokens_like_go()
|
||||
{
|
||||
SubjectMatch.IsSubsetMatchTokenized(["foo", "bar"], ["foo", ">"]).ShouldBeTrue();
|
||||
SubjectMatch.IsSubsetMatchTokenized(["foo", "bar"], ["foo", ""]).ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Tests.Subscriptions;
|
||||
|
||||
public class SubjectTransformParityBatch3Tests
|
||||
{
|
||||
[Fact]
|
||||
public void ValidateMapping_accepts_supported_templates_and_rejects_invalid_templates()
|
||||
{
|
||||
SubjectTransform.ValidateMapping("dest.$1").ShouldBeTrue();
|
||||
SubjectTransform.ValidateMapping("dest.{{partition(10)}}").ShouldBeTrue();
|
||||
SubjectTransform.ValidateMapping("dest.{{random(5)}}").ShouldBeTrue();
|
||||
|
||||
SubjectTransform.ValidateMapping("dest.*").ShouldBeFalse();
|
||||
SubjectTransform.ValidateMapping("dest.{{wildcard()}}").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewSubjectTransformStrict_requires_all_source_wildcards_to_be_used()
|
||||
{
|
||||
SubjectTransform.NewSubjectTransformWithStrict("foo.*.*", "bar.$1", strict: true).ShouldBeNull();
|
||||
SubjectTransform.NewSubjectTransformWithStrict("foo.*.*", "bar.$1", strict: false).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewSubjectTransformStrict_accepts_when_all_source_wildcards_are_used()
|
||||
{
|
||||
var transform = SubjectTransform.NewSubjectTransformStrict("foo.*.*", "bar.$2.$1");
|
||||
transform.ShouldNotBeNull();
|
||||
transform.Apply("foo.A.B").ShouldBe("bar.B.A");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Random_transform_function_returns_bucket_in_range()
|
||||
{
|
||||
var transform = SubjectTransform.Create("*", "rand.{{random(3)}}");
|
||||
transform.ShouldNotBeNull();
|
||||
|
||||
for (var i = 0; i < 20; i++)
|
||||
{
|
||||
var output = transform.Apply("foo");
|
||||
output.ShouldNotBeNull();
|
||||
var parts = output!.Split('.');
|
||||
parts.Length.ShouldBe(2);
|
||||
int.TryParse(parts[1], out var bucket).ShouldBeTrue();
|
||||
bucket.ShouldBeGreaterThanOrEqualTo(0);
|
||||
bucket.ShouldBeLessThan(3);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TransformTokenize_and_transformUntokenize_round_trip_wildcards()
|
||||
{
|
||||
var tokenized = SubjectTransform.TransformTokenize("foo.*.*");
|
||||
tokenized.ShouldBe("foo.$1.$2");
|
||||
|
||||
var untokenized = SubjectTransform.TransformUntokenize(tokenized);
|
||||
untokenized.ShouldBe("foo.*.*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reverse_produces_inverse_transform_for_reordered_wildcards()
|
||||
{
|
||||
var forward = SubjectTransform.Create("foo.*.*", "bar.$2.$1");
|
||||
forward.ShouldNotBeNull();
|
||||
|
||||
var reverse = forward.Reverse();
|
||||
reverse.ShouldNotBeNull();
|
||||
|
||||
var mapped = forward.Apply("foo.A.B");
|
||||
mapped.ShouldBe("bar.B.A");
|
||||
reverse.Apply(mapped!).ShouldBe("foo.A.B");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TransformSubject_applies_transform_without_source_match_guard()
|
||||
{
|
||||
var transform = SubjectTransform.Create("foo.*", "bar.$1");
|
||||
transform.ShouldNotBeNull();
|
||||
|
||||
transform.TransformSubject("baz.qux").ShouldBe("bar.qux");
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,45 @@ public class TlsHelperTests
|
||||
finally { File.Delete(certPath); File.Delete(keyPath); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadCaCertificates_rejects_non_certificate_pem_block()
|
||||
{
|
||||
var (_, key) = GenerateTestCert();
|
||||
var pemPath = Path.GetTempFileName();
|
||||
try
|
||||
{
|
||||
File.WriteAllText(pemPath, key.ExportPkcs8PrivateKeyPem());
|
||||
Should.Throw<InvalidDataException>(() => TlsHelper.LoadCaCertificates(pemPath));
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(pemPath);
|
||||
key.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadCaCertificates_loads_multiple_certificate_blocks()
|
||||
{
|
||||
var (certA, keyA) = GenerateTestCert();
|
||||
var (certB, keyB) = GenerateTestCert();
|
||||
var pemPath = Path.GetTempFileName();
|
||||
try
|
||||
{
|
||||
File.WriteAllText(pemPath, certA.ExportCertificatePem() + certB.ExportCertificatePem());
|
||||
var collection = TlsHelper.LoadCaCertificates(pemPath);
|
||||
collection.Count.ShouldBe(2);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(pemPath);
|
||||
certA.Dispose();
|
||||
certB.Dispose();
|
||||
keyA.Dispose();
|
||||
keyB.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchesPinnedCert_matches_correct_hash()
|
||||
{
|
||||
|
||||
133
tests/NATS.Server.Tests/TlsOcspParityBatch1Tests.cs
Normal file
133
tests/NATS.Server.Tests/TlsOcspParityBatch1Tests.cs
Normal file
@@ -0,0 +1,133 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using NATS.Server.Configuration;
|
||||
using NATS.Server.Tls;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class TlsOcspParityBatch1Tests
|
||||
{
|
||||
[Fact]
|
||||
public void OCSPPeerConfig_defaults_match_go_reference()
|
||||
{
|
||||
var cfg = OCSPPeerConfig.NewOCSPPeerConfig();
|
||||
|
||||
cfg.Verify.ShouldBeFalse();
|
||||
cfg.Timeout.ShouldBe(2d);
|
||||
cfg.ClockSkew.ShouldBe(30d);
|
||||
cfg.WarnOnly.ShouldBeFalse();
|
||||
cfg.UnknownIsGood.ShouldBeFalse();
|
||||
cfg.AllowWhenCAUnreachable.ShouldBeFalse();
|
||||
cfg.TTLUnsetNextUpdate.ShouldBe(3600d);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OCSPPeerConfig_parse_map_parses_supported_fields()
|
||||
{
|
||||
var cfg = OCSPPeerConfig.Parse(new Dictionary<string, object?>
|
||||
{
|
||||
["verify"] = true,
|
||||
["allowed_clockskew"] = "45s",
|
||||
["ca_timeout"] = 1.5d,
|
||||
["cache_ttl_when_next_update_unset"] = 120L,
|
||||
["warn_only"] = true,
|
||||
["unknown_is_good"] = true,
|
||||
["allow_when_ca_unreachable"] = true,
|
||||
});
|
||||
|
||||
cfg.Verify.ShouldBeTrue();
|
||||
cfg.ClockSkew.ShouldBe(45d);
|
||||
cfg.Timeout.ShouldBe(1.5d);
|
||||
cfg.TTLUnsetNextUpdate.ShouldBe(120d);
|
||||
cfg.WarnOnly.ShouldBeTrue();
|
||||
cfg.UnknownIsGood.ShouldBeTrue();
|
||||
cfg.AllowWhenCAUnreachable.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OCSPPeerConfig_parse_unknown_field_throws()
|
||||
{
|
||||
var ex = Should.Throw<FormatException>(() =>
|
||||
OCSPPeerConfig.Parse(new Dictionary<string, object?> { ["bogus"] = true }));
|
||||
|
||||
ex.Message.ShouldContain("unknown field [bogus]");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigProcessor_parses_ocsp_peer_short_form()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfig("""
|
||||
tls {
|
||||
ocsp_peer: true
|
||||
}
|
||||
""");
|
||||
|
||||
opts.OcspPeerVerify.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigProcessor_parses_ocsp_peer_long_form_verify()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfig("""
|
||||
tls {
|
||||
ocsp_peer {
|
||||
verify: true
|
||||
ca_timeout: 2s
|
||||
allowed_clockskew: 30s
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
opts.OcspPeerVerify.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateFingerprint_uses_raw_certificate_sha256()
|
||||
{
|
||||
var (cert, _) = TlsHelperTests.GenerateTestCert();
|
||||
|
||||
var expected = Convert.ToBase64String(SHA256.HashData(cert.RawData));
|
||||
TlsHelper.GenerateFingerprint(cert).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetWebEndpoints_filters_non_web_uris()
|
||||
{
|
||||
var urls = TlsHelper.GetWebEndpoints(
|
||||
["http://a.example", "https://b.example", "ftp://bad.example", "not a uri"]);
|
||||
|
||||
urls.Count.ShouldBe(2);
|
||||
urls[0].Scheme.ShouldBe(Uri.UriSchemeHttp);
|
||||
urls[1].Scheme.ShouldBe(Uri.UriSchemeHttps);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Subject_and_issuer_dn_helpers_return_values_and_empty_for_null()
|
||||
{
|
||||
var (cert, _) = TlsHelperTests.GenerateTestCert();
|
||||
|
||||
TlsHelper.GetSubjectDNForm(cert).ShouldNotBeNullOrWhiteSpace();
|
||||
TlsHelper.GetIssuerDNForm(cert).ShouldNotBeNullOrWhiteSpace();
|
||||
TlsHelper.GetSubjectDNForm(null).ShouldBe(string.Empty);
|
||||
TlsHelper.GetIssuerDNForm(null).ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StatusAssertion_json_converter_uses_string_values_and_unknown_fallback()
|
||||
{
|
||||
var revokedJson = JsonSerializer.Serialize(StatusAssertion.Revoked);
|
||||
revokedJson.ShouldBe("\"revoked\"");
|
||||
|
||||
var unknown = JsonSerializer.Deserialize<StatusAssertion>("\"nonsense\"");
|
||||
unknown.ShouldBe(StatusAssertion.Unknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OcspPeer_messages_match_go_literals()
|
||||
{
|
||||
OcspPeerMessages.MsgTLSClientRejectConnection.ShouldBe("client not OCSP valid");
|
||||
OcspPeerMessages.MsgTLSServerRejectConnection.ShouldBe("server not OCSP valid");
|
||||
OcspPeerMessages.MsgCacheOnline.ShouldBe("OCSP peer cache online, type [%s]");
|
||||
OcspPeerMessages.MsgCacheOffline.ShouldBe("OCSP peer cache offline, type [%s]");
|
||||
}
|
||||
}
|
||||
165
tests/NATS.Server.Tests/TlsOcspParityBatch2Tests.cs
Normal file
165
tests/NATS.Server.Tests/TlsOcspParityBatch2Tests.cs
Normal file
@@ -0,0 +1,165 @@
|
||||
using System.Formats.Asn1;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using NATS.Server.Tls;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class TlsOcspParityBatch2Tests
|
||||
{
|
||||
[Fact]
|
||||
public void CertOCSPEligible_returns_true_and_populates_endpoints_for_http_ocsp_aia()
|
||||
{
|
||||
using var cert = CreateLeafWithOcspAia("http://ocsp.example.test");
|
||||
var link = new ChainLink { Leaf = cert };
|
||||
|
||||
var eligible = TlsHelper.CertOCSPEligible(link);
|
||||
|
||||
eligible.ShouldBeTrue();
|
||||
link.OCSPWebEndpoints.ShouldNotBeNull();
|
||||
link.OCSPWebEndpoints!.Count.ShouldBe(1);
|
||||
link.OCSPWebEndpoints[0].ToString().ShouldBe("http://ocsp.example.test/");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CertOCSPEligible_returns_false_when_leaf_has_no_ocsp_servers()
|
||||
{
|
||||
var (leaf, _) = TlsHelperTests.GenerateTestCert();
|
||||
var link = new ChainLink { Leaf = leaf };
|
||||
|
||||
TlsHelper.CertOCSPEligible(link).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLeafIssuerCert_returns_positional_issuer_or_null()
|
||||
{
|
||||
using var root = CreateRootCertificate();
|
||||
using var leaf = CreateLeafSignedBy(root);
|
||||
|
||||
var chain = new[] { leaf, root };
|
||||
TlsHelper.GetLeafIssuerCert(chain, 0).ShouldBe(root);
|
||||
TlsHelper.GetLeafIssuerCert(chain, 1).ShouldBeNull();
|
||||
TlsHelper.GetLeafIssuerCert(chain, -1).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLeafIssuer_returns_verified_issuer_from_chain()
|
||||
{
|
||||
using var root = CreateRootCertificate();
|
||||
using var leaf = CreateLeafSignedBy(root);
|
||||
|
||||
using var issuer = TlsHelper.GetLeafIssuer(leaf, root);
|
||||
|
||||
issuer.ShouldNotBeNull();
|
||||
issuer!.Thumbprint.ShouldBe(root.Thumbprint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OcspResponseCurrent_applies_skew_and_ttl_rules()
|
||||
{
|
||||
var opts = OCSPPeerConfig.NewOCSPPeerConfig();
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
TlsHelper.OcspResponseCurrent(new OcspResponseInfo
|
||||
{
|
||||
ThisUpdate = now.AddMinutes(-1),
|
||||
NextUpdate = now.AddMinutes(5),
|
||||
}, opts).ShouldBeTrue();
|
||||
|
||||
TlsHelper.OcspResponseCurrent(new OcspResponseInfo
|
||||
{
|
||||
ThisUpdate = now.AddHours(-2),
|
||||
NextUpdate = null,
|
||||
}, opts).ShouldBeFalse();
|
||||
|
||||
TlsHelper.OcspResponseCurrent(new OcspResponseInfo
|
||||
{
|
||||
ThisUpdate = now.AddMinutes(2),
|
||||
NextUpdate = now.AddHours(1),
|
||||
}, opts).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidDelegationCheck_accepts_direct_and_ocsp_signing_delegate()
|
||||
{
|
||||
using var issuer = CreateRootCertificate();
|
||||
using var delegateCert = CreateOcspSigningDelegate(issuer);
|
||||
|
||||
TlsHelper.ValidDelegationCheck(issuer, null).ShouldBeTrue();
|
||||
TlsHelper.ValidDelegationCheck(issuer, issuer).ShouldBeTrue();
|
||||
TlsHelper.ValidDelegationCheck(issuer, delegateCert).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OcspPeerMessages_exposes_error_and_debug_constants()
|
||||
{
|
||||
OcspPeerMessages.ErrIllegalPeerOptsConfig.ShouldContain("expected map to define OCSP peer options");
|
||||
OcspPeerMessages.ErrNoAvailOCSPServers.ShouldBe("no available OCSP servers");
|
||||
OcspPeerMessages.DbgPlugTLSForKind.ShouldBe("Plugging TLS OCSP peer for [%s]");
|
||||
OcspPeerMessages.DbgCacheSaved.ShouldBe("Saved OCSP peer cache successfully (%d bytes)");
|
||||
OcspPeerMessages.MsgFailedOCSPResponseFetch.ShouldBe("Failed OCSP response fetch");
|
||||
}
|
||||
|
||||
private static X509Certificate2 CreateLeafWithOcspAia(string ocspUri)
|
||||
{
|
||||
using var key = RSA.Create(2048);
|
||||
var req = new CertificateRequest("CN=leaf-with-ocsp", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
req.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false));
|
||||
req.CertificateExtensions.Add(CreateOcspAiaExtension(ocspUri));
|
||||
return req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(30));
|
||||
}
|
||||
|
||||
private static X509Extension CreateOcspAiaExtension(string ocspUri)
|
||||
{
|
||||
var writer = new AsnWriter(AsnEncodingRules.DER);
|
||||
writer.PushSequence();
|
||||
writer.PushSequence();
|
||||
writer.WriteObjectIdentifier("1.3.6.1.5.5.7.48.1");
|
||||
writer.WriteCharacterString(UniversalTagNumber.IA5String, ocspUri, new Asn1Tag(TagClass.ContextSpecific, 6));
|
||||
writer.PopSequence();
|
||||
writer.PopSequence();
|
||||
return new X509Extension("1.3.6.1.5.5.7.1.1", writer.Encode(), false);
|
||||
}
|
||||
|
||||
private static X509Certificate2 CreateRootCertificate()
|
||||
{
|
||||
using var rootKey = RSA.Create(2048);
|
||||
var req = new CertificateRequest("CN=Root", rootKey, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
req.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
|
||||
req.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, true));
|
||||
return req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(5));
|
||||
}
|
||||
|
||||
private static X509Certificate2 CreateLeafSignedBy(X509Certificate2 issuer)
|
||||
{
|
||||
using var leafKey = RSA.Create(2048);
|
||||
var req = new CertificateRequest("CN=Leaf", leafKey, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
req.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true));
|
||||
req.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(req.PublicKey, false));
|
||||
|
||||
var cert = req.Create(
|
||||
issuer,
|
||||
DateTimeOffset.UtcNow.AddDays(-1),
|
||||
DateTimeOffset.UtcNow.AddYears(1),
|
||||
Guid.NewGuid().ToByteArray());
|
||||
|
||||
return cert.CopyWithPrivateKey(leafKey);
|
||||
}
|
||||
|
||||
private static X509Certificate2 CreateOcspSigningDelegate(X509Certificate2 issuer)
|
||||
{
|
||||
using var key = RSA.Create(2048);
|
||||
var req = new CertificateRequest("CN=OCSP Delegate", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
req.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true));
|
||||
req.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(
|
||||
[new Oid("1.3.6.1.5.5.7.3.9")], true));
|
||||
|
||||
var cert = req.Create(
|
||||
issuer,
|
||||
DateTimeOffset.UtcNow.AddDays(-1),
|
||||
DateTimeOffset.UtcNow.AddYears(1),
|
||||
Guid.NewGuid().ToByteArray());
|
||||
|
||||
return cert.CopyWithPrivateKey(key);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using Shouldly;
|
||||
using NATS.Server.WebSocket;
|
||||
|
||||
namespace NATS.Server.Tests.WebSocket;
|
||||
|
||||
@@ -23,4 +24,27 @@ public class WebSocketOptionsTests
|
||||
opts.WebSocket.ShouldNotBeNull();
|
||||
opts.WebSocket.Port.ShouldBe(-1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WsAuthConfig_sets_auth_override_when_websocket_auth_fields_are_present()
|
||||
{
|
||||
var ws = new WebSocketOptions
|
||||
{
|
||||
Username = "u",
|
||||
};
|
||||
|
||||
WsAuthConfig.Apply(ws);
|
||||
|
||||
ws.AuthOverride.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WsAuthConfig_keeps_auth_override_false_when_no_ws_auth_fields_are_present()
|
||||
{
|
||||
var ws = new WebSocketOptions();
|
||||
|
||||
WsAuthConfig.Apply(ws);
|
||||
|
||||
ws.AuthOverride.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.WebSocket;
|
||||
|
||||
namespace NATS.Server.Tests.WebSocket;
|
||||
|
||||
public class WebSocketOptionsValidatorParityBatch2Tests
|
||||
{
|
||||
[Fact]
|
||||
public void Validate_rejects_tls_listener_without_cert_key_when_not_no_tls()
|
||||
{
|
||||
var opts = new NatsOptions
|
||||
{
|
||||
WebSocket = new WebSocketOptions
|
||||
{
|
||||
Port = 8080,
|
||||
NoTls = false,
|
||||
},
|
||||
};
|
||||
|
||||
var result = WebSocketOptionsValidator.Validate(opts);
|
||||
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Errors.ShouldContain(e => e.Contains("TLS", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_rejects_invalid_allowed_origins()
|
||||
{
|
||||
var opts = new NatsOptions
|
||||
{
|
||||
WebSocket = new WebSocketOptions
|
||||
{
|
||||
Port = 8080,
|
||||
NoTls = true,
|
||||
AllowedOrigins = ["not-a-uri"],
|
||||
},
|
||||
};
|
||||
|
||||
var result = WebSocketOptionsValidator.Validate(opts);
|
||||
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Errors.ShouldContain(e => e.Contains("allowed origin", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_rejects_no_auth_user_not_present_in_configured_users()
|
||||
{
|
||||
var opts = new NatsOptions
|
||||
{
|
||||
Users = [new User { Username = "alice", Password = "x" }],
|
||||
WebSocket = new WebSocketOptions
|
||||
{
|
||||
Port = 8080,
|
||||
NoTls = true,
|
||||
NoAuthUser = "bob",
|
||||
},
|
||||
};
|
||||
|
||||
var result = WebSocketOptionsValidator.Validate(opts);
|
||||
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Errors.ShouldContain(e => e.Contains("NoAuthUser", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_rejects_username_or_token_when_users_or_nkeys_are_set()
|
||||
{
|
||||
var opts = new NatsOptions
|
||||
{
|
||||
Users = [new User { Username = "alice", Password = "x" }],
|
||||
WebSocket = new WebSocketOptions
|
||||
{
|
||||
Port = 8080,
|
||||
NoTls = true,
|
||||
Username = "ws-user",
|
||||
},
|
||||
};
|
||||
|
||||
var result = WebSocketOptionsValidator.Validate(opts);
|
||||
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Errors.ShouldContain(e => e.Contains("users", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_rejects_jwt_cookie_without_trusted_operators()
|
||||
{
|
||||
var opts = new NatsOptions
|
||||
{
|
||||
WebSocket = new WebSocketOptions
|
||||
{
|
||||
Port = 8080,
|
||||
NoTls = true,
|
||||
JwtCookie = "jwt",
|
||||
},
|
||||
};
|
||||
|
||||
var result = WebSocketOptionsValidator.Validate(opts);
|
||||
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Errors.ShouldContain(e => e.Contains("JwtCookie", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_rejects_reserved_response_headers_override()
|
||||
{
|
||||
var opts = new NatsOptions
|
||||
{
|
||||
TrustedKeys = ["OP1"],
|
||||
WebSocket = new WebSocketOptions
|
||||
{
|
||||
Port = 8080,
|
||||
NoTls = true,
|
||||
Headers = new Dictionary<string, string>
|
||||
{
|
||||
["Sec-WebSocket-Accept"] = "bad",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
var result = WebSocketOptionsValidator.Validate(opts);
|
||||
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Errors.ShouldContain(e => e.Contains("reserved", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_rejects_tls_pinned_certs_when_websocket_tls_is_disabled()
|
||||
{
|
||||
var opts = new NatsOptions
|
||||
{
|
||||
TlsPinnedCerts = ["ABCDEF0123"],
|
||||
WebSocket = new WebSocketOptions
|
||||
{
|
||||
Port = 8080,
|
||||
NoTls = true,
|
||||
},
|
||||
};
|
||||
|
||||
var result = WebSocketOptionsValidator.Validate(opts);
|
||||
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Errors.ShouldContain(e => e.Contains("TLSPinnedCerts", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_accepts_valid_minimal_configuration()
|
||||
{
|
||||
var opts = new NatsOptions
|
||||
{
|
||||
TrustedKeys = ["OP1"],
|
||||
Users = [new User { Username = "alice", Password = "x" }],
|
||||
WebSocket = new WebSocketOptions
|
||||
{
|
||||
Port = 8080,
|
||||
NoTls = true,
|
||||
NoAuthUser = "alice",
|
||||
AllowedOrigins = ["https://app.example.com"],
|
||||
JwtCookie = "jwt",
|
||||
Headers = new Dictionary<string, string>
|
||||
{
|
||||
["X-App-Version"] = "1",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
var result = WebSocketOptionsValidator.Validate(opts);
|
||||
|
||||
result.IsValid.ShouldBeTrue();
|
||||
result.Errors.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.Text;
|
||||
using NATS.Server.WebSocket;
|
||||
|
||||
namespace NATS.Server.Tests.WebSocket;
|
||||
|
||||
public class WsUpgradeHelperParityBatch1Tests
|
||||
{
|
||||
[Fact]
|
||||
public void MakeChallengeKey_returns_base64_of_16_random_bytes()
|
||||
{
|
||||
var key = WsUpgrade.MakeChallengeKey();
|
||||
var decoded = Convert.FromBase64String(key);
|
||||
|
||||
decoded.Length.ShouldBe(16);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Url_helpers_match_ws_and_wss_schemes()
|
||||
{
|
||||
WsUpgrade.IsWsUrl("ws://localhost:8080").ShouldBeTrue();
|
||||
WsUpgrade.IsWsUrl("wss://localhost:8443").ShouldBeFalse();
|
||||
WsUpgrade.IsWsUrl("http://localhost").ShouldBeFalse();
|
||||
|
||||
WsUpgrade.IsWssUrl("wss://localhost:8443").ShouldBeTrue();
|
||||
WsUpgrade.IsWssUrl("ws://localhost:8080").ShouldBeFalse();
|
||||
WsUpgrade.IsWssUrl("https://localhost").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RejectNoMaskingForTest_forces_no_masking_handshake_rejection()
|
||||
{
|
||||
var request = BuildValidRequest("/leafnode", "Nats-No-Masking: true\r\n");
|
||||
using var input = new MemoryStream(Encoding.ASCII.GetBytes(request));
|
||||
using var output = new MemoryStream();
|
||||
|
||||
try
|
||||
{
|
||||
WsUpgrade.RejectNoMaskingForTest = true;
|
||||
var result = await WsUpgrade.TryUpgradeAsync(input, output, new WebSocketOptions { NoTls = true });
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
output.Position = 0;
|
||||
var response = Encoding.ASCII.GetString(output.ToArray());
|
||||
response.ShouldContain("400 Bad Request");
|
||||
response.ShouldContain("invalid value for no-masking");
|
||||
}
|
||||
finally
|
||||
{
|
||||
WsUpgrade.RejectNoMaskingForTest = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildValidRequest(string path = "/", string extraHeaders = "")
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append($"GET {path} HTTP/1.1\r\n");
|
||||
sb.Append("Host: localhost:8080\r\n");
|
||||
sb.Append("Upgrade: websocket\r\n");
|
||||
sb.Append("Connection: Upgrade\r\n");
|
||||
sb.Append("Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n");
|
||||
sb.Append("Sec-WebSocket-Version: 13\r\n");
|
||||
sb.Append(extraHeaders);
|
||||
sb.Append("\r\n");
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user