Close all 5 server-side wiring gaps so E2E tests pass without skips: - System events: bridge user-defined system_account to internal $SYS - Account imports/exports: config parsing + reverse response import for cross-account request-reply - Subject transforms: parse mappings config block, apply in ProcessMessage - JWT auth: parse trusted_keys, resolver MEMORY, resolver_preload in config - Service latency: timestamp on request, publish ServiceLatencyMsg on response
353 lines
12 KiB
C#
353 lines
12 KiB
C#
using System.Text;
|
|
using System.Text.Json;
|
|
using NATS.Client.Core;
|
|
using NATS.E2E.Tests.Infrastructure;
|
|
using NATS.NKeys;
|
|
|
|
namespace NATS.E2E.Tests;
|
|
|
|
public class AdvancedTests
|
|
{
|
|
[Fact]
|
|
public async Task ConfigFile_FullConfig_ServerStartsAndAcceptsConnections()
|
|
{
|
|
var config = """
|
|
server_name: e2e-config-test
|
|
max_payload: 2048
|
|
max_connections: 100
|
|
""";
|
|
|
|
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}" });
|
|
await client.ConnectAsync();
|
|
await client.PingAsync();
|
|
|
|
client.ConnectionState.ShouldBe(NatsConnectionState.Open);
|
|
client.ServerInfo.ShouldNotBeNull();
|
|
client.ServerInfo!.MaxPayload.ShouldBe(2048);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MaxConnections_ExceedsLimit_Rejected()
|
|
{
|
|
var config = """
|
|
max_connections: 2
|
|
""";
|
|
|
|
await using var server = NatsServerProcess.WithConfig(config);
|
|
await server.StartAsync();
|
|
|
|
var url = $"nats://127.0.0.1:{server.Port}";
|
|
|
|
await using var c1 = new NatsConnection(new NatsOpts { Url = url });
|
|
await using var c2 = new NatsConnection(new NatsOpts { Url = url });
|
|
|
|
await c1.ConnectAsync();
|
|
await c1.PingAsync();
|
|
|
|
await c2.ConnectAsync();
|
|
await c2.PingAsync();
|
|
|
|
await using var c3 = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = url,
|
|
MaxReconnectRetry = 0,
|
|
});
|
|
|
|
var ex = await Should.ThrowAsync<Exception>(async () =>
|
|
{
|
|
await c3.ConnectAsync();
|
|
await c3.PingAsync();
|
|
});
|
|
|
|
ex.ShouldNotBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SystemEvents_ClientConnect_EventPublished()
|
|
{
|
|
var config = """
|
|
accounts {
|
|
SYS {
|
|
users = [{ user: "sys", password: "sys" }]
|
|
}
|
|
APP {
|
|
users = [{ user: "app", password: "app" }]
|
|
}
|
|
}
|
|
system_account: SYS
|
|
""";
|
|
|
|
await using var server = NatsServerProcess.WithConfig(config);
|
|
await server.StartAsync();
|
|
|
|
var url = $"nats://127.0.0.1:{server.Port}";
|
|
|
|
await using var sysClient = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = url,
|
|
AuthOpts = new NatsAuthOpts { Username = "sys", Password = "sys" },
|
|
});
|
|
await sysClient.ConnectAsync();
|
|
|
|
await using var subscription = await sysClient.SubscribeCoreAsync<string>("$SYS.ACCOUNT.*.CONNECT");
|
|
await sysClient.PingAsync();
|
|
|
|
await using var appClient = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = url,
|
|
AuthOpts = new NatsAuthOpts { Username = "app", Password = "app" },
|
|
});
|
|
await appClient.ConnectAsync();
|
|
await appClient.PingAsync();
|
|
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
|
var msg = await subscription.Msgs.ReadAsync(cts.Token);
|
|
msg.Subject.ShouldContain("CONNECT");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AccountImportExport_CrossAccountServiceCall()
|
|
{
|
|
var config = """
|
|
accounts {
|
|
PROVIDER {
|
|
users = [{ user: "provider", password: "prov" }]
|
|
exports = [
|
|
{ service: "svc.echo" }
|
|
]
|
|
}
|
|
CONSUMER {
|
|
users = [{ user: "consumer", password: "cons" }]
|
|
imports = [
|
|
{ service: { account: PROVIDER, subject: "svc.echo" } }
|
|
]
|
|
}
|
|
}
|
|
""";
|
|
|
|
await using var server = NatsServerProcess.WithConfig(config);
|
|
await server.StartAsync();
|
|
|
|
var url = $"nats://127.0.0.1:{server.Port}";
|
|
|
|
await using var provider = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = url,
|
|
AuthOpts = new NatsAuthOpts { Username = "provider", Password = "prov" },
|
|
});
|
|
await provider.ConnectAsync();
|
|
|
|
await using var svcSub = await provider.SubscribeCoreAsync<string>("svc.echo");
|
|
await provider.PingAsync();
|
|
|
|
var responderTask = Task.Run(async () =>
|
|
{
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
|
var msg = await svcSub.Msgs.ReadAsync(cts.Token);
|
|
await provider.PublishAsync(msg.ReplyTo!, $"echo: {msg.Data}");
|
|
});
|
|
|
|
await using var consumer = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = url,
|
|
AuthOpts = new NatsAuthOpts { Username = "consumer", Password = "cons" },
|
|
});
|
|
await consumer.ConnectAsync();
|
|
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
|
var reply = await consumer.RequestAsync<string, string>("svc.echo", "hello", cancellationToken: cts.Token);
|
|
|
|
reply.Data.ShouldBe("echo: hello");
|
|
|
|
await responderTask;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ServiceLatency_CrossAccountCall_LatencyMessagePublished()
|
|
{
|
|
var config = """
|
|
accounts {
|
|
SYS {
|
|
users = [{ user: "sys", password: "sys" }]
|
|
}
|
|
PROVIDER {
|
|
users = [{ user: "provider", password: "prov" }]
|
|
exports = [
|
|
{ service: "svc.echo", latency: "latency.svc.echo" }
|
|
]
|
|
}
|
|
CONSUMER {
|
|
users = [{ user: "consumer", password: "cons" }]
|
|
imports = [
|
|
{ service: { account: PROVIDER, subject: "svc.echo" } }
|
|
]
|
|
}
|
|
}
|
|
system_account: SYS
|
|
""";
|
|
|
|
await using var server = NatsServerProcess.WithConfig(config);
|
|
await server.StartAsync();
|
|
|
|
var url = $"nats://127.0.0.1:{server.Port}";
|
|
|
|
// System account client subscribes to latency events
|
|
await using var sysClient = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = url,
|
|
AuthOpts = new NatsAuthOpts { Username = "sys", Password = "sys" },
|
|
});
|
|
await sysClient.ConnectAsync();
|
|
|
|
await using var latencySub = await sysClient.SubscribeCoreAsync<string>("latency.svc.echo");
|
|
await sysClient.PingAsync();
|
|
|
|
// Provider sets up the echo responder
|
|
await using var provider = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = url,
|
|
AuthOpts = new NatsAuthOpts { Username = "provider", Password = "prov" },
|
|
});
|
|
await provider.ConnectAsync();
|
|
|
|
await using var svcSub = await provider.SubscribeCoreAsync<string>("svc.echo");
|
|
await provider.PingAsync();
|
|
|
|
var responderTask = Task.Run(async () =>
|
|
{
|
|
using var cts2 = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
|
var msg = await svcSub.Msgs.ReadAsync(cts2.Token);
|
|
await provider.PublishAsync(msg.ReplyTo!, $"echo: {msg.Data}");
|
|
});
|
|
|
|
// Consumer makes a cross-account service call
|
|
await using var consumer = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = url,
|
|
AuthOpts = new NatsAuthOpts { Username = "consumer", Password = "cons" },
|
|
});
|
|
await consumer.ConnectAsync();
|
|
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
|
var reply = await consumer.RequestAsync<string, string>("svc.echo", "hello", cancellationToken: cts.Token);
|
|
reply.Data.ShouldBe("echo: hello");
|
|
|
|
await responderTask;
|
|
|
|
// Verify latency message was published to the system account
|
|
var latencyMsg = await latencySub.Msgs.ReadAsync(cts.Token);
|
|
latencyMsg.Subject.ShouldBe("latency.svc.echo");
|
|
latencyMsg.Data.ShouldNotBeNull();
|
|
latencyMsg.Data!.ShouldContain("service_latency");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SubjectTransforms_MappedSubject_ReceivedOnTarget()
|
|
{
|
|
var config = """
|
|
mappings {
|
|
"e2e.src": "e2e.dest"
|
|
}
|
|
""";
|
|
|
|
await using var server = NatsServerProcess.WithConfig(config);
|
|
await server.StartAsync();
|
|
|
|
var url = $"nats://127.0.0.1:{server.Port}";
|
|
|
|
await using var client = new NatsConnection(new NatsOpts { Url = url });
|
|
await client.ConnectAsync();
|
|
|
|
// Subscribe to the destination subject
|
|
await using var sub = await client.SubscribeCoreAsync<string>("e2e.dest");
|
|
await client.PingAsync();
|
|
|
|
// Publish to the source subject — should be transformed to destination
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
await client.PublishAsync("e2e.src", "mapped-payload", cancellationToken: cts.Token);
|
|
await client.PingAsync(cts.Token);
|
|
|
|
var msg = await sub.Msgs.ReadAsync(cts.Token);
|
|
msg.Data.ShouldBe("mapped-payload");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task JwtAuth_ValidJwt_Connects()
|
|
{
|
|
// Generate operator, account, and user NKey pairs
|
|
using var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
|
|
using var accountKp = KeyPair.CreatePair(PrefixByte.Account);
|
|
using var userKp = KeyPair.CreatePair(PrefixByte.User);
|
|
|
|
var operatorPub = operatorKp.GetPublicKey();
|
|
var accountPub = accountKp.GetPublicKey();
|
|
var userPub = userKp.GetPublicKey();
|
|
|
|
// Build account JWT (signed by operator)
|
|
var accountJwt = BuildJwt(new
|
|
{
|
|
sub = accountPub,
|
|
iss = operatorPub,
|
|
iat = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
|
|
nats = new { type = "account", version = 2 },
|
|
}, operatorKp);
|
|
|
|
// Build user JWT as bearer token (signed by account, no nonce needed)
|
|
var userJwt = BuildJwt(new
|
|
{
|
|
sub = userPub,
|
|
iss = accountPub,
|
|
iat = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
|
|
nats = new { type = "user", version = 2, bearer_token = true, issuer_account = accountPub },
|
|
}, accountKp);
|
|
|
|
var config = $$"""
|
|
trusted_keys: "{{operatorPub}}"
|
|
resolver: MEMORY
|
|
resolver_preload: {
|
|
{{accountPub}}: "{{accountJwt}}"
|
|
}
|
|
""";
|
|
|
|
await using var server = NatsServerProcess.WithConfig(config);
|
|
await server.StartAsync();
|
|
|
|
var url = $"nats://127.0.0.1:{server.Port}";
|
|
|
|
await using var client = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = url,
|
|
AuthOpts = new NatsAuthOpts { Jwt = userJwt },
|
|
});
|
|
|
|
await client.ConnectAsync();
|
|
await client.PingAsync();
|
|
client.ConnectionState.ShouldBe(NatsConnectionState.Open);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds a signed NATS JWT using the given payload and NKey pair.
|
|
/// Wire format: base64url(header).base64url(payload).base64url(ed25519-signature).
|
|
/// </summary>
|
|
private static string BuildJwt(object payload, KeyPair signingKp)
|
|
{
|
|
var header = """{"typ":"jwt","alg":"ed25519-nkey"}""";
|
|
var payloadJson = JsonSerializer.Serialize(payload);
|
|
|
|
var headerB64 = Base64UrlEncode(Encoding.UTF8.GetBytes(header));
|
|
var payloadB64 = Base64UrlEncode(Encoding.UTF8.GetBytes(payloadJson));
|
|
|
|
var signingInput = Encoding.UTF8.GetBytes($"{headerB64}.{payloadB64}");
|
|
var sig = new byte[64];
|
|
signingKp.Sign(signingInput, sig);
|
|
|
|
return $"{headerB64}.{payloadB64}.{Base64UrlEncode(sig)}";
|
|
}
|
|
|
|
private static string Base64UrlEncode(byte[] data)
|
|
=> Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
|
}
|