Files
natsdotnet/tests/NATS.Server.Tests/ImportExportTests.cs
Joseph Doherty c9066e526d feat: wire service import forwarding into message delivery path
Add ProcessServiceImport method to NatsServer that transforms subjects
from importer to exporter namespace and delivers to destination account
subscribers. Wire service import checking into ProcessMessage so that
publishes matching a service import "From" pattern are automatically
forwarded to the destination account. Includes MapImportSubject for
wildcard-aware subject mapping and WireServiceImports for import setup.
2026-02-23 05:59:36 -05:00

339 lines
12 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;
public class ImportExportTests
{
[Fact]
public void ExportAuth_public_export_authorizes_any_account()
{
var auth = new ExportAuth();
var account = new Account("test");
auth.IsAuthorized(account).ShouldBeTrue();
}
[Fact]
public void ExportAuth_approved_accounts_restricts_access()
{
var auth = new ExportAuth { ApprovedAccounts = ["allowed"] };
var allowed = new Account("allowed");
var denied = new Account("denied");
auth.IsAuthorized(allowed).ShouldBeTrue();
auth.IsAuthorized(denied).ShouldBeFalse();
}
[Fact]
public void ExportAuth_revoked_account_denied()
{
var auth = new ExportAuth
{
ApprovedAccounts = ["test"],
RevokedAccounts = new() { ["test"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds() },
};
var account = new Account("test");
auth.IsAuthorized(account).ShouldBeFalse();
}
[Fact]
public void ServiceResponseType_defaults_to_singleton()
{
var import = new ServiceImport
{
DestinationAccount = new Account("dest"),
From = "requests.>",
To = "api.>",
};
import.ResponseType.ShouldBe(ServiceResponseType.Singleton);
}
[Fact]
public void ExportMap_stores_and_retrieves_exports()
{
var map = new ExportMap();
map.Services["api.>"] = new ServiceExport { Account = new Account("svc") };
map.Streams["events.>"] = new StreamExport();
map.Services.ShouldContainKey("api.>");
map.Streams.ShouldContainKey("events.>");
}
[Fact]
public void ImportMap_stores_service_imports()
{
var map = new ImportMap();
var si = new ServiceImport
{
DestinationAccount = new Account("dest"),
From = "requests.>",
To = "api.>",
};
map.AddServiceImport(si);
map.Services.ShouldContainKey("requests.>");
map.Services["requests.>"].Count.ShouldBe(1);
}
[Fact]
public void Account_add_service_export_and_import()
{
var exporter = new Account("exporter");
var importer = new Account("importer");
exporter.AddServiceExport("api.>", ServiceResponseType.Singleton, null);
exporter.Exports.Services.ShouldContainKey("api.>");
var si = importer.AddServiceImport(exporter, "requests.>", "api.>");
si.ShouldNotBeNull();
si.From.ShouldBe("requests.>");
si.To.ShouldBe("api.>");
si.DestinationAccount.ShouldBe(exporter);
importer.Imports.Services.ShouldContainKey("requests.>");
}
[Fact]
public void Account_add_stream_export_and_import()
{
var exporter = new Account("exporter");
var importer = new Account("importer");
exporter.AddStreamExport("events.>", null);
exporter.Exports.Streams.ShouldContainKey("events.>");
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.>");
}
[Fact]
public void Account_service_import_auth_rejected()
{
var exporter = new Account("exporter");
var importer = new Account("importer");
exporter.AddServiceExport("api.>", ServiceResponseType.Singleton, [new Account("other")]);
Should.Throw<UnauthorizedAccessException>(() =>
importer.AddServiceImport(exporter, "requests.>", "api.>"));
}
[Fact]
public void Account_lazy_creates_internal_client()
{
var account = new Account("test");
var client = account.GetOrCreateInternalClient(99);
client.ShouldNotBeNull();
client.Kind.ShouldBe(ClientKind.Account);
client.Account.ShouldBe(account);
// Second call returns same instance
var client2 = account.GetOrCreateInternalClient(100);
client2.ShouldBeSameAs(client);
}
[Fact]
public async Task Service_import_forwards_message_to_export_account()
{
using var server = CreateTestServer();
_ = server.StartAsync(CancellationToken.None);
await server.WaitForReadyAsync();
// Set up exporter and importer accounts
var exporter = server.GetOrCreateAccount("exporter");
var importer = server.GetOrCreateAccount("importer");
exporter.AddServiceExport("api.>", ServiceResponseType.Singleton, null);
importer.AddServiceImport(exporter, "requests.>", "api.>");
// Wire the import subscriptions into the importer account
server.WireServiceImports(importer);
// Subscribe in exporter account to receive forwarded message
var exportSub = new Subscription { Subject = "api.test", Sid = "export-1", Client = null };
exporter.SubList.Insert(exportSub);
// Verify import infrastructure is wired: the importer should have service import entries
importer.Imports.Services.ShouldContainKey("requests.>");
importer.Imports.Services["requests.>"].Count.ShouldBe(1);
importer.Imports.Services["requests.>"][0].DestinationAccount.ShouldBe(exporter);
await server.ShutdownAsync();
}
[Fact]
public void ProcessServiceImport_delivers_to_destination_account_subscribers()
{
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.>");
// Add a subscriber in the exporter account's SubList
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 = "api.test", Sid = "s1", Client = mockClient };
exporter.SubList.Insert(exportSub);
// Process a service import directly
var si = importer.Imports.Services["requests.>"][0];
server.ProcessServiceImport(si, "requests.test", null,
ReadOnlyMemory<byte>.Empty, ReadOnlyMemory<byte>.Empty);
received.Count.ShouldBe(1);
received[0].Subject.ShouldBe("api.test");
received[0].Sid.ShouldBe("s1");
}
[Fact]
public void ProcessServiceImport_with_transform_applies_subject_mapping()
{
using var server = CreateTestServer();
var exporter = server.GetOrCreateAccount("exporter");
var importer = server.GetOrCreateAccount("importer");
exporter.AddServiceExport("api.>", ServiceResponseType.Singleton, null);
var si = importer.AddServiceImport(exporter, "requests.>", "api.>");
// Create a transform from requests.> to api.>
var transform = SubjectTransform.Create("requests.>", "api.>");
transform.ShouldNotBeNull();
// Create a new import with the transform set
var siWithTransform = new ServiceImport
{
DestinationAccount = exporter,
From = "requests.>",
To = "api.>",
Transform = transform,
};
var received = new List<string>();
var mockClient = new TestNatsClient(1, exporter);
mockClient.OnMessage = (subject, _, _, _, _) =>
received.Add(subject);
var exportSub = new Subscription { Subject = "api.hello", Sid = "s1", Client = mockClient };
exporter.SubList.Insert(exportSub);
server.ProcessServiceImport(siWithTransform, "requests.hello", null,
ReadOnlyMemory<byte>.Empty, ReadOnlyMemory<byte>.Empty);
received.Count.ShouldBe(1);
received[0].ShouldBe("api.hello");
}
[Fact]
public void ProcessServiceImport_skips_invalid_imports()
{
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.>");
// Mark the import as invalid
var si = importer.Imports.Services["requests.>"][0];
si.Invalid = true;
// Add a subscriber in the exporter account
var received = new List<string>();
var mockClient = new TestNatsClient(1, exporter);
mockClient.OnMessage = (subject, _, _, _, _) =>
received.Add(subject);
var exportSub = new Subscription { Subject = "api.test", Sid = "s1", Client = mockClient };
exporter.SubList.Insert(exportSub);
// ProcessServiceImport should be a no-op for invalid imports
server.ProcessServiceImport(si, "requests.test", null,
ReadOnlyMemory<byte>.Empty, ReadOnlyMemory<byte>.Empty);
received.Count.ShouldBe(0);
}
[Fact]
public void ProcessServiceImport_delivers_to_queue_groups()
{
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.>");
// Add queue group subscribers in the exporter account
var received = new List<(string Subject, string Sid)>();
var mockClient1 = new TestNatsClient(1, exporter);
mockClient1.OnMessage = (subject, sid, _, _, _) =>
received.Add((subject, sid));
var mockClient2 = new TestNatsClient(2, exporter);
mockClient2.OnMessage = (subject, sid, _, _, _) =>
received.Add((subject, sid));
var qSub1 = new Subscription { Subject = "api.test", Sid = "q1", Queue = "workers", Client = mockClient1 };
var qSub2 = new Subscription { Subject = "api.test", Sid = "q2", Queue = "workers", Client = mockClient2 };
exporter.SubList.Insert(qSub1);
exporter.SubList.Insert(qSub2);
var si = importer.Imports.Services["requests.>"][0];
server.ProcessServiceImport(si, "requests.test", null,
ReadOnlyMemory<byte>.Empty, ReadOnlyMemory<byte>.Empty);
// One member of the queue group should receive the message
received.Count.ShouldBe(1);
}
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) { }
}
}