Files
natsdotnet/tests/NATS.Server.Tests/Accounts/AccountImportExportTests.cs
Joseph Doherty f1353868af feat: Wave 6 batch 2 — accounts/auth, gateways, routes, JetStream API, JetStream cluster tests
Add comprehensive Go-parity test coverage across 5 subsystems:
- Accounts/Auth: isolation, import/export, auth mechanisms, permissions (82 tests)
- Gateways: connection, forwarding, interest mode, config (106 tests)
- Routes: connection, subscription, forwarding, config validation (78 tests)
- JetStream API: stream/consumer CRUD, pub/sub, features, admin (234 tests)
- JetStream Cluster: streams, consumers, failover, meta (108 tests)

Total: ~608 new test annotations across 22 files (+13,844 lines)
All tests pass individually; suite total: 2,283 passing, 3 skipped
2026-02-23 22:35:06 -05:00

421 lines
16 KiB
C#

using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server;
using NATS.Server.Auth;
using NATS.Server.Imports;
using NATS.Server.Subscriptions;
namespace NATS.Server.Tests.Accounts;
/// <summary>
/// Tests for cross-account stream/service export/import delivery, authorization, and mapping.
/// Reference: Go accounts_test.go TestAccountIsolationExportImport, TestMultiAccountsIsolation,
/// TestImportAuthorized, TestSimpleMapping, TestAddServiceExport, TestAddStreamExport,
/// TestServiceExportWithWildcards, TestServiceImportWithWildcards, etc.
/// </summary>
public class AccountImportExportTests
{
// Go: TestAccountIsolationExportImport server/accounts_test.go:111
[Fact]
public void Stream_export_import_delivers_cross_account()
{
using var server = CreateTestServer();
var exporter = server.GetOrCreateAccount("acct-a");
var importer = server.GetOrCreateAccount("acct-b");
// Account A exports "events.>"
exporter.AddStreamExport("events.>", null);
exporter.Exports.Streams.ShouldContainKey("events.>");
// Account B imports "events.>" from Account A
importer.AddStreamImport(exporter, "events.>", "imported.events.>");
importer.Imports.Streams.Count.ShouldBe(1);
importer.Imports.Streams[0].From.ShouldBe("events.>");
importer.Imports.Streams[0].To.ShouldBe("imported.events.>");
importer.Imports.Streams[0].SourceAccount.ShouldBe(exporter);
// Also set up a service export/import to verify cross-account message delivery
exporter.AddServiceExport("svc.>", ServiceResponseType.Singleton, null);
importer.AddServiceImport(exporter, "requests.>", "svc.>");
var received = new List<(string Subject, string Sid)>();
var mockClient = new TestNatsClient(1, exporter);
mockClient.OnMessage = (subject, sid, _, _, _) =>
received.Add((subject, sid));
var exportSub = new Subscription { Subject = "svc.order", Sid = "s1", Client = mockClient };
exporter.SubList.Insert(exportSub);
var si = importer.Imports.Services["requests.>"][0];
server.ProcessServiceImport(si, "requests.order", null,
ReadOnlyMemory<byte>.Empty, ReadOnlyMemory<byte>.Empty);
received.Count.ShouldBe(1);
received[0].Subject.ShouldBe("svc.order");
received[0].Sid.ShouldBe("s1");
}
// Go: TestMultiAccountsIsolation server/accounts_test.go:304
[Fact]
public void Account_isolation_prevents_cross_account_delivery()
{
using var server = CreateTestServer();
var accountA = server.GetOrCreateAccount("acct-a");
var accountB = server.GetOrCreateAccount("acct-b");
var accountC = server.GetOrCreateAccount("acct-c");
accountA.SubList.ShouldNotBeSameAs(accountB.SubList);
accountB.SubList.ShouldNotBeSameAs(accountC.SubList);
var receivedA = new List<string>();
var receivedB = new List<string>();
var receivedC = new List<string>();
var clientA = new TestNatsClient(1, accountA);
clientA.OnMessage = (subject, _, _, _, _) => receivedA.Add(subject);
var clientB = new TestNatsClient(2, accountB);
clientB.OnMessage = (subject, _, _, _, _) => receivedB.Add(subject);
var clientC = new TestNatsClient(3, accountC);
clientC.OnMessage = (subject, _, _, _, _) => receivedC.Add(subject);
accountA.SubList.Insert(new Subscription { Subject = "orders.>", Sid = "a1", Client = clientA });
accountB.SubList.Insert(new Subscription { Subject = "orders.>", Sid = "b1", Client = clientB });
accountC.SubList.Insert(new Subscription { Subject = "orders.>", Sid = "c1", Client = clientC });
var resultA = accountA.SubList.Match("orders.client.stream.entry");
resultA.PlainSubs.Length.ShouldBe(1);
foreach (var sub in resultA.PlainSubs)
sub.Client?.SendMessage("orders.client.stream.entry", sub.Sid, null, default, default);
receivedA.Count.ShouldBe(1);
receivedB.Count.ShouldBe(0);
receivedC.Count.ShouldBe(0);
}
// Go: TestAddStreamExport server/accounts_test.go:1560
[Fact]
public void Add_stream_export_public()
{
using var server = CreateTestServer();
var foo = server.GetOrCreateAccount("foo");
foo.AddStreamExport("foo", null);
foo.Exports.Streams.ShouldContainKey("foo");
// Public export (no approved list) should be authorized for anyone
var bar = server.GetOrCreateAccount("bar");
foo.Exports.Streams["foo"].Auth.IsAuthorized(bar).ShouldBeTrue();
}
// Go: TestAddStreamExport server/accounts_test.go:1560
[Fact]
public void Add_stream_export_with_approved_accounts()
{
using var server = CreateTestServer();
var foo = server.GetOrCreateAccount("foo");
var bar = server.GetOrCreateAccount("bar");
var baz = server.GetOrCreateAccount("baz");
foo.AddStreamExport("events.>", [bar]);
foo.Exports.Streams["events.>"].Auth.IsAuthorized(bar).ShouldBeTrue();
foo.Exports.Streams["events.>"].Auth.IsAuthorized(baz).ShouldBeFalse();
}
// Go: TestAddServiceExport server/accounts_test.go:1282
[Fact]
public void Add_service_export_singleton()
{
using var server = CreateTestServer();
var foo = server.GetOrCreateAccount("foo");
foo.AddServiceExport("help", ServiceResponseType.Singleton, null);
foo.Exports.Services.ShouldContainKey("help");
foo.Exports.Services["help"].ResponseType.ShouldBe(ServiceResponseType.Singleton);
}
// Go: TestAddServiceExport server/accounts_test.go:1282
[Fact]
public void Add_service_export_streamed()
{
using var server = CreateTestServer();
var foo = server.GetOrCreateAccount("foo");
foo.AddServiceExport("data.feed", ServiceResponseType.Streamed, null);
foo.Exports.Services["data.feed"].ResponseType.ShouldBe(ServiceResponseType.Streamed);
}
// Go: TestAddServiceExport server/accounts_test.go:1282
[Fact]
public void Add_service_export_chunked()
{
using var server = CreateTestServer();
var foo = server.GetOrCreateAccount("foo");
foo.AddServiceExport("photos", ServiceResponseType.Chunked, null);
foo.Exports.Services["photos"].ResponseType.ShouldBe(ServiceResponseType.Chunked);
}
// Go: TestServiceExportWithWildcards server/accounts_test.go:1319
[Fact]
public void Service_export_with_wildcard_subject()
{
using var server = CreateTestServer();
var foo = server.GetOrCreateAccount("foo");
foo.AddServiceExport("svc.*", ServiceResponseType.Singleton, null);
foo.Exports.Services.ShouldContainKey("svc.*");
}
// Go: TestImportAuthorized server/accounts_test.go:761
[Fact]
public void Stream_import_requires_matching_export()
{
using var server = CreateTestServer();
var foo = server.GetOrCreateAccount("foo");
var bar = server.GetOrCreateAccount("bar");
// Without export, import should fail
Should.Throw<InvalidOperationException>(() =>
bar.AddStreamImport(foo, "foo", "import"));
}
// Go: TestImportAuthorized server/accounts_test.go:761
[Fact]
public void Stream_import_with_public_export_succeeds()
{
using var server = CreateTestServer();
var foo = server.GetOrCreateAccount("foo");
var bar = server.GetOrCreateAccount("bar");
foo.AddStreamExport("foo", null); // public
bar.AddStreamImport(foo, "foo", "import");
bar.Imports.Streams.Count.ShouldBe(1);
bar.Imports.Streams[0].From.ShouldBe("foo");
}
// Go: TestImportAuthorized server/accounts_test.go:761
[Fact]
public void Stream_import_from_restricted_export_unauthorized_account_fails()
{
using var server = CreateTestServer();
var foo = server.GetOrCreateAccount("foo");
var bar = server.GetOrCreateAccount("bar");
var baz = server.GetOrCreateAccount("baz");
foo.AddStreamExport("data.>", [bar]); // only bar authorized
// bar can import
bar.AddStreamImport(foo, "data.>", "imported");
bar.Imports.Streams.Count.ShouldBe(1);
// baz cannot import
Should.Throw<UnauthorizedAccessException>(() =>
baz.AddStreamImport(foo, "data.>", "imported"));
}
// Go: TestServiceImportWithWildcards server/accounts_test.go:1463
[Fact]
public void Service_import_delivers_to_exporter_via_wildcard()
{
using var server = CreateTestServer();
var exporter = server.GetOrCreateAccount("exporter");
var importer = server.GetOrCreateAccount("importer");
exporter.AddServiceExport("api.>", ServiceResponseType.Singleton, null);
importer.AddServiceImport(exporter, "requests.>", "api.>");
var received = new List<string>();
var mockClient = new TestNatsClient(1, exporter);
mockClient.OnMessage = (subject, _, _, _, _) => received.Add(subject);
exporter.SubList.Insert(new Subscription { Subject = "api.orders", Sid = "s1", Client = mockClient });
var si = importer.Imports.Services["requests.>"][0];
server.ProcessServiceImport(si, "requests.orders", null, default, default);
received.Count.ShouldBe(1);
received[0].ShouldBe("api.orders");
}
// Go: TestSimpleMapping server/accounts_test.go:845
[Fact]
public void Service_import_maps_literal_subjects()
{
using var server = CreateTestServer();
var foo = server.GetOrCreateAccount("foo");
var bar = server.GetOrCreateAccount("bar");
foo.AddServiceExport("help.request", ServiceResponseType.Singleton, null);
bar.AddServiceImport(foo, "local.help", "help.request");
var received = new List<string>();
var mockClient = new TestNatsClient(1, foo);
mockClient.OnMessage = (subject, _, _, _, _) => received.Add(subject);
foo.SubList.Insert(new Subscription { Subject = "help.request", Sid = "s1", Client = mockClient });
var si = bar.Imports.Services["local.help"][0];
server.ProcessServiceImport(si, "local.help", null, default, default);
received.Count.ShouldBe(1);
received[0].ShouldBe("help.request");
}
// Go: TestAccountDuplicateServiceImportSubject server/accounts_test.go:2411
[Fact]
public void Duplicate_service_import_same_from_subject_adds_to_list()
{
using var server = CreateTestServer();
var foo = server.GetOrCreateAccount("foo");
var bar = server.GetOrCreateAccount("bar");
var baz = server.GetOrCreateAccount("baz");
foo.AddServiceExport("svc.a", ServiceResponseType.Singleton, null);
baz.AddServiceExport("svc.a", ServiceResponseType.Singleton, null);
bar.AddServiceImport(foo, "requests.a", "svc.a");
bar.AddServiceImport(baz, "requests.a", "svc.a");
// Both imports should be stored under the same "from" key
bar.Imports.Services["requests.a"].Count.ShouldBe(2);
}
// Go: TestCrossAccountRequestReply server/accounts_test.go:1597
[Fact]
public void Service_import_preserves_reply_to()
{
using var server = CreateTestServer();
var exporter = server.GetOrCreateAccount("exporter");
var importer = server.GetOrCreateAccount("importer");
exporter.AddServiceExport("api.time", ServiceResponseType.Singleton, null);
importer.AddServiceImport(exporter, "time.request", "api.time");
string? capturedReply = null;
var mockClient = new TestNatsClient(1, exporter);
mockClient.OnMessage = (_, _, replyTo, _, _) => capturedReply = replyTo;
exporter.SubList.Insert(new Subscription { Subject = "api.time", Sid = "s1", Client = mockClient });
var si = importer.Imports.Services["time.request"][0];
server.ProcessServiceImport(si, "time.request", "_INBOX.abc123", default, default);
capturedReply.ShouldBe("_INBOX.abc123");
}
// Go: TestAccountRemoveServiceImport server/accounts_test.go:2447
[Fact]
public void Service_import_invalid_flag_prevents_delivery()
{
using var server = CreateTestServer();
var exporter = server.GetOrCreateAccount("exporter");
var importer = server.GetOrCreateAccount("importer");
exporter.AddServiceExport("api.>", ServiceResponseType.Singleton, null);
importer.AddServiceImport(exporter, "requests.>", "api.>");
var received = new List<string>();
var mockClient = new TestNatsClient(1, exporter);
mockClient.OnMessage = (subject, _, _, _, _) => received.Add(subject);
exporter.SubList.Insert(new Subscription { Subject = "api.test", Sid = "s1", Client = mockClient });
// Mark the import as invalid
var si = importer.Imports.Services["requests.>"][0];
si.Invalid = true;
server.ProcessServiceImport(si, "requests.test", null, default, default);
received.Count.ShouldBe(0);
}
// Go: TestAccountCheckStreamImportsEqual server/accounts_test.go:2274
[Fact]
public void Stream_import_tracks_source_account()
{
using var server = CreateTestServer();
var foo = server.GetOrCreateAccount("foo");
var bar = server.GetOrCreateAccount("bar");
foo.AddStreamExport("data.>", null);
bar.AddStreamImport(foo, "data.>", "feed.>");
var si = bar.Imports.Streams[0];
si.SourceAccount.ShouldBeSameAs(foo);
si.From.ShouldBe("data.>");
si.To.ShouldBe("feed.>");
}
// Go: TestExportAuth — revoked accounts cannot import
[Fact]
public void Revoked_account_cannot_access_export()
{
using var server = CreateTestServer();
var foo = server.GetOrCreateAccount("foo");
var bar = server.GetOrCreateAccount("bar");
var auth = new ExportAuth
{
RevokedAccounts = new Dictionary<string, long> { [bar.Name] = DateTimeOffset.UtcNow.ToUnixTimeSeconds() },
};
auth.IsAuthorized(bar).ShouldBeFalse();
}
// Go: TestExportAuth — public export with no restrictions
[Fact]
public void Public_export_authorizes_any_account()
{
using var server = CreateTestServer();
var foo = server.GetOrCreateAccount("foo");
var bar = server.GetOrCreateAccount("bar");
var auth = new ExportAuth(); // No restrictions
auth.IsAuthorized(foo).ShouldBeTrue();
auth.IsAuthorized(bar).ShouldBeTrue();
}
private static NatsServer CreateTestServer()
{
var port = GetFreePort();
return new NatsServer(new NatsOptions { Port = port }, NullLoggerFactory.Instance);
}
private static int GetFreePort()
{
using var sock = new System.Net.Sockets.Socket(
System.Net.Sockets.AddressFamily.InterNetwork,
System.Net.Sockets.SocketType.Stream,
System.Net.Sockets.ProtocolType.Tcp);
sock.Bind(new System.Net.IPEndPoint(System.Net.IPAddress.Loopback, 0));
return ((System.Net.IPEndPoint)sock.LocalEndPoint!).Port;
}
/// <summary>
/// Minimal test double for INatsClient used in import/export tests.
/// </summary>
private sealed class TestNatsClient(ulong id, Account account) : INatsClient
{
public ulong Id => id;
public ClientKind Kind => ClientKind.Client;
public Account? Account => account;
public Protocol.ClientOptions? ClientOpts => null;
public ClientPermissions? Permissions => null;
public Action<string, string, string?, ReadOnlyMemory<byte>, ReadOnlyMemory<byte>>? OnMessage { get; set; }
public void SendMessage(string subject, string sid, string? replyTo,
ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload)
{
OnMessage?.Invoke(subject, sid, replyTo, headers, payload);
}
public bool QueueOutbound(ReadOnlyMemory<byte> data) => true;
public void RemoveSubscription(string sid) { }
}
}