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
600 lines
18 KiB
C#
600 lines
18 KiB
C#
using System.Net;
|
|
using System.Net.Sockets;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using NATS.Client.Core;
|
|
using NATS.Server;
|
|
using NATS.Server.Auth;
|
|
using NATS.Server.Protocol;
|
|
|
|
namespace NATS.Server.Tests.Accounts;
|
|
|
|
/// <summary>
|
|
/// Tests for authentication mechanisms: username/password, token, NKey-based auth,
|
|
/// no-auth-user fallback, multi-user, and AuthService orchestration.
|
|
/// Reference: Go auth_test.go — TestUserClone*, TestNoAuthUser, TestUserConnectionDeadline, etc.
|
|
/// Reference: Go accounts_test.go — TestAccountMapsUsers.
|
|
/// </summary>
|
|
public class AuthMechanismTests
|
|
{
|
|
private static int GetFreePort()
|
|
{
|
|
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
|
sock.Bind(new IPEndPoint(IPAddress.Loopback, 0));
|
|
return ((IPEndPoint)sock.LocalEndPoint!).Port;
|
|
}
|
|
|
|
private static async Task<(NatsServer server, int port, CancellationTokenSource cts)> StartServerAsync(NatsOptions options)
|
|
{
|
|
var port = GetFreePort();
|
|
options.Port = port;
|
|
var server = new NatsServer(options, NullLoggerFactory.Instance);
|
|
var cts = new CancellationTokenSource();
|
|
_ = server.StartAsync(cts.Token);
|
|
await server.WaitForReadyAsync();
|
|
return (server, port, cts);
|
|
}
|
|
|
|
private static bool ExceptionChainContains(Exception ex, string substring)
|
|
{
|
|
Exception? current = ex;
|
|
while (current != null)
|
|
{
|
|
if (current.Message.Contains(substring, StringComparison.OrdinalIgnoreCase))
|
|
return true;
|
|
current = current.InnerException;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Go: TestUserCloneNilPermissions server/auth_test.go:34
|
|
[Fact]
|
|
public void User_with_nil_permissions()
|
|
{
|
|
var user = new User
|
|
{
|
|
Username = "foo",
|
|
Password = "bar",
|
|
};
|
|
|
|
user.Permissions.ShouldBeNull();
|
|
}
|
|
|
|
// Go: TestUserClone server/auth_test.go:53
|
|
[Fact]
|
|
public void User_with_permissions_has_correct_fields()
|
|
{
|
|
var user = new User
|
|
{
|
|
Username = "foo",
|
|
Password = "bar",
|
|
Permissions = new Permissions
|
|
{
|
|
Publish = new SubjectPermission { Allow = ["foo"] },
|
|
Subscribe = new SubjectPermission { Allow = ["bar"] },
|
|
},
|
|
};
|
|
|
|
user.Username.ShouldBe("foo");
|
|
user.Password.ShouldBe("bar");
|
|
user.Permissions.ShouldNotBeNull();
|
|
user.Permissions.Publish!.Allow![0].ShouldBe("foo");
|
|
user.Permissions.Subscribe!.Allow![0].ShouldBe("bar");
|
|
}
|
|
|
|
// Go: TestUserClonePermissionsNoLists server/auth_test.go:80
|
|
[Fact]
|
|
public void User_with_empty_permissions()
|
|
{
|
|
var user = new User
|
|
{
|
|
Username = "foo",
|
|
Password = "bar",
|
|
Permissions = new Permissions(),
|
|
};
|
|
|
|
user.Permissions!.Publish.ShouldBeNull();
|
|
user.Permissions!.Subscribe.ShouldBeNull();
|
|
}
|
|
|
|
// Go: TestNoAuthUser (token auth success) server/auth_test.go:225
|
|
[Fact]
|
|
public async Task Token_auth_success()
|
|
{
|
|
var (server, port, cts) = await StartServerAsync(new NatsOptions
|
|
{
|
|
Authorization = "s3cr3t",
|
|
});
|
|
|
|
try
|
|
{
|
|
await using var client = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://s3cr3t@127.0.0.1:{port}",
|
|
});
|
|
await client.ConnectAsync();
|
|
await client.PingAsync();
|
|
}
|
|
finally
|
|
{
|
|
await cts.CancelAsync();
|
|
server.Dispose();
|
|
}
|
|
}
|
|
|
|
// Go: auth mechanism — token auth failure
|
|
[Fact]
|
|
public async Task Token_auth_failure_disconnects()
|
|
{
|
|
var (server, port, cts) = await StartServerAsync(new NatsOptions
|
|
{
|
|
Authorization = "s3cr3t",
|
|
});
|
|
|
|
try
|
|
{
|
|
await using var client = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://wrongtoken@127.0.0.1:{port}",
|
|
MaxReconnectRetry = 0,
|
|
});
|
|
|
|
var ex = await Should.ThrowAsync<NatsException>(async () =>
|
|
{
|
|
await client.ConnectAsync();
|
|
await client.PingAsync();
|
|
});
|
|
|
|
ExceptionChainContains(ex, "Authorization Violation").ShouldBeTrue(
|
|
$"Expected 'Authorization Violation' in exception chain, but got: {ex}");
|
|
}
|
|
finally
|
|
{
|
|
await cts.CancelAsync();
|
|
server.Dispose();
|
|
}
|
|
}
|
|
|
|
// Go: auth mechanism — user/password success
|
|
[Fact]
|
|
public async Task UserPassword_auth_success()
|
|
{
|
|
var (server, port, cts) = await StartServerAsync(new NatsOptions
|
|
{
|
|
Username = "admin",
|
|
Password = "secret",
|
|
});
|
|
|
|
try
|
|
{
|
|
await using var client = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://admin:secret@127.0.0.1:{port}",
|
|
});
|
|
await client.ConnectAsync();
|
|
await client.PingAsync();
|
|
}
|
|
finally
|
|
{
|
|
await cts.CancelAsync();
|
|
server.Dispose();
|
|
}
|
|
}
|
|
|
|
// Go: auth mechanism — user/password failure
|
|
[Fact]
|
|
public async Task UserPassword_auth_failure_disconnects()
|
|
{
|
|
var (server, port, cts) = await StartServerAsync(new NatsOptions
|
|
{
|
|
Username = "admin",
|
|
Password = "secret",
|
|
});
|
|
|
|
try
|
|
{
|
|
await using var client = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://admin:wrong@127.0.0.1:{port}",
|
|
MaxReconnectRetry = 0,
|
|
});
|
|
|
|
var ex = await Should.ThrowAsync<NatsException>(async () =>
|
|
{
|
|
await client.ConnectAsync();
|
|
await client.PingAsync();
|
|
});
|
|
|
|
ExceptionChainContains(ex, "Authorization Violation").ShouldBeTrue(
|
|
$"Expected 'Authorization Violation' in exception chain, but got: {ex}");
|
|
}
|
|
finally
|
|
{
|
|
await cts.CancelAsync();
|
|
server.Dispose();
|
|
}
|
|
}
|
|
|
|
// Go: TestNoAuthUser server/auth_test.go:225 — multi-user auth
|
|
[Fact]
|
|
public async Task MultiUser_auth_each_user_succeeds()
|
|
{
|
|
var (server, port, cts) = await StartServerAsync(new NatsOptions
|
|
{
|
|
Users =
|
|
[
|
|
new User { Username = "alice", Password = "pass1" },
|
|
new User { Username = "bob", Password = "pass2" },
|
|
],
|
|
});
|
|
|
|
try
|
|
{
|
|
await using var alice = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://alice:pass1@127.0.0.1:{port}",
|
|
});
|
|
await using var bob = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://bob:pass2@127.0.0.1:{port}",
|
|
});
|
|
|
|
await alice.ConnectAsync();
|
|
await alice.PingAsync();
|
|
await bob.ConnectAsync();
|
|
await bob.PingAsync();
|
|
}
|
|
finally
|
|
{
|
|
await cts.CancelAsync();
|
|
server.Dispose();
|
|
}
|
|
}
|
|
|
|
// Go: TestNoAuthUser server/auth_test.go:225 — wrong user password
|
|
[Fact]
|
|
public async Task MultiUser_wrong_password_fails()
|
|
{
|
|
var (server, port, cts) = await StartServerAsync(new NatsOptions
|
|
{
|
|
Users =
|
|
[
|
|
new User { Username = "alice", Password = "pass1" },
|
|
],
|
|
});
|
|
|
|
try
|
|
{
|
|
await using var client = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://alice:wrong@127.0.0.1:{port}",
|
|
MaxReconnectRetry = 0,
|
|
});
|
|
|
|
var ex = await Should.ThrowAsync<NatsException>(async () =>
|
|
{
|
|
await client.ConnectAsync();
|
|
await client.PingAsync();
|
|
});
|
|
|
|
ExceptionChainContains(ex, "Authorization Violation").ShouldBeTrue(
|
|
$"Expected 'Authorization Violation', but got: {ex}");
|
|
}
|
|
finally
|
|
{
|
|
await cts.CancelAsync();
|
|
server.Dispose();
|
|
}
|
|
}
|
|
|
|
// Go: auth mechanism — no credentials with auth required
|
|
[Fact]
|
|
public async Task No_credentials_when_auth_required_disconnects()
|
|
{
|
|
var (server, port, cts) = await StartServerAsync(new NatsOptions
|
|
{
|
|
Authorization = "s3cr3t",
|
|
});
|
|
|
|
try
|
|
{
|
|
await using var client = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://127.0.0.1:{port}",
|
|
MaxReconnectRetry = 0,
|
|
});
|
|
|
|
var ex = await Should.ThrowAsync<NatsException>(async () =>
|
|
{
|
|
await client.ConnectAsync();
|
|
await client.PingAsync();
|
|
});
|
|
|
|
ExceptionChainContains(ex, "Authorization Violation").ShouldBeTrue(
|
|
$"Expected 'Authorization Violation', but got: {ex}");
|
|
}
|
|
finally
|
|
{
|
|
await cts.CancelAsync();
|
|
server.Dispose();
|
|
}
|
|
}
|
|
|
|
// Go: auth mechanism — no auth configured allows all
|
|
[Fact]
|
|
public async Task No_auth_configured_allows_all()
|
|
{
|
|
var (server, port, cts) = await StartServerAsync(new NatsOptions());
|
|
|
|
try
|
|
{
|
|
await using var client = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://127.0.0.1:{port}",
|
|
});
|
|
await client.ConnectAsync();
|
|
await client.PingAsync();
|
|
}
|
|
finally
|
|
{
|
|
await cts.CancelAsync();
|
|
server.Dispose();
|
|
}
|
|
}
|
|
|
|
// Go: TestNoAuthUser server/auth_test.go:225 — no_auth_user fallback
|
|
[Fact]
|
|
public async Task NoAuthUser_fallback_allows_unauthenticated_connection()
|
|
{
|
|
var (server, port, cts) = await StartServerAsync(new NatsOptions
|
|
{
|
|
Users =
|
|
[
|
|
new User { Username = "foo", Password = "pwd1", Account = "FOO" },
|
|
new User { Username = "bar", Password = "pwd2", Account = "BAR" },
|
|
],
|
|
NoAuthUser = "foo",
|
|
});
|
|
|
|
try
|
|
{
|
|
// Connect without credentials — should use no_auth_user "foo"
|
|
await using var client = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://127.0.0.1:{port}",
|
|
});
|
|
await client.ConnectAsync();
|
|
await client.PingAsync();
|
|
|
|
// Explicit auth also still works
|
|
await using var bar = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://bar:pwd2@127.0.0.1:{port}",
|
|
});
|
|
await bar.ConnectAsync();
|
|
await bar.PingAsync();
|
|
}
|
|
finally
|
|
{
|
|
await cts.CancelAsync();
|
|
server.Dispose();
|
|
}
|
|
}
|
|
|
|
// Go: TestNoAuthUser server/auth_test.go:225 — invalid pwd with no_auth_user still fails
|
|
[Fact]
|
|
public async Task NoAuthUser_wrong_password_still_fails()
|
|
{
|
|
var (server, port, cts) = await StartServerAsync(new NatsOptions
|
|
{
|
|
Users =
|
|
[
|
|
new User { Username = "foo", Password = "pwd1", Account = "FOO" },
|
|
new User { Username = "bar", Password = "pwd2", Account = "BAR" },
|
|
],
|
|
NoAuthUser = "foo",
|
|
});
|
|
|
|
try
|
|
{
|
|
await using var client = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://bar:wrong@127.0.0.1:{port}",
|
|
MaxReconnectRetry = 0,
|
|
});
|
|
|
|
var ex = await Should.ThrowAsync<NatsException>(async () =>
|
|
{
|
|
await client.ConnectAsync();
|
|
await client.PingAsync();
|
|
});
|
|
|
|
ExceptionChainContains(ex, "Authorization Violation").ShouldBeTrue(
|
|
$"Expected auth violation, got: {ex}");
|
|
}
|
|
finally
|
|
{
|
|
await cts.CancelAsync();
|
|
server.Dispose();
|
|
}
|
|
}
|
|
|
|
// Go: AuthService — tests the build logic for auth service
|
|
[Fact]
|
|
public void AuthService_build_with_no_auth_returns_not_required()
|
|
{
|
|
var authService = AuthService.Build(new NatsOptions());
|
|
authService.IsAuthRequired.ShouldBeFalse();
|
|
authService.NonceRequired.ShouldBeFalse();
|
|
}
|
|
|
|
// Go: AuthService — tests the build logic for token auth
|
|
[Fact]
|
|
public void AuthService_build_with_token_marks_auth_required()
|
|
{
|
|
var authService = AuthService.Build(new NatsOptions { Authorization = "secret" });
|
|
authService.IsAuthRequired.ShouldBeTrue();
|
|
authService.NonceRequired.ShouldBeFalse();
|
|
}
|
|
|
|
// Go: AuthService — tests the build logic for user/password auth
|
|
[Fact]
|
|
public void AuthService_build_with_user_password_marks_auth_required()
|
|
{
|
|
var authService = AuthService.Build(new NatsOptions
|
|
{
|
|
Username = "admin",
|
|
Password = "secret",
|
|
});
|
|
authService.IsAuthRequired.ShouldBeTrue();
|
|
authService.NonceRequired.ShouldBeFalse();
|
|
}
|
|
|
|
// Go: AuthService — tests the build logic for nkey auth
|
|
[Fact]
|
|
public void AuthService_build_with_nkeys_marks_nonce_required()
|
|
{
|
|
var authService = AuthService.Build(new NatsOptions
|
|
{
|
|
NKeys = [new NKeyUser { Nkey = "UABC123" }],
|
|
});
|
|
authService.IsAuthRequired.ShouldBeTrue();
|
|
authService.NonceRequired.ShouldBeTrue();
|
|
}
|
|
|
|
// Go: AuthService — tests the build logic for multi-user auth
|
|
[Fact]
|
|
public void AuthService_build_with_users_marks_auth_required()
|
|
{
|
|
var authService = AuthService.Build(new NatsOptions
|
|
{
|
|
Users = [new User { Username = "alice", Password = "pass" }],
|
|
});
|
|
authService.IsAuthRequired.ShouldBeTrue();
|
|
}
|
|
|
|
// Go: AuthService.Authenticate — token match
|
|
[Fact]
|
|
public void AuthService_authenticate_token_success()
|
|
{
|
|
var authService = AuthService.Build(new NatsOptions { Authorization = "mytoken" });
|
|
var result = authService.Authenticate(new ClientAuthContext
|
|
{
|
|
Opts = new ClientOptions { Token = "mytoken" },
|
|
Nonce = [],
|
|
});
|
|
|
|
result.ShouldNotBeNull();
|
|
result.Identity.ShouldBe("token");
|
|
}
|
|
|
|
// Go: AuthService.Authenticate — token mismatch
|
|
[Fact]
|
|
public void AuthService_authenticate_token_failure()
|
|
{
|
|
var authService = AuthService.Build(new NatsOptions { Authorization = "mytoken" });
|
|
var result = authService.Authenticate(new ClientAuthContext
|
|
{
|
|
Opts = new ClientOptions { Token = "wrong" },
|
|
Nonce = [],
|
|
});
|
|
|
|
result.ShouldBeNull();
|
|
}
|
|
|
|
// Go: AuthService.Authenticate — user/password match
|
|
[Fact]
|
|
public void AuthService_authenticate_user_password_success()
|
|
{
|
|
var authService = AuthService.Build(new NatsOptions
|
|
{
|
|
Users = [new User { Username = "alice", Password = "pass", Account = "acct-a" }],
|
|
});
|
|
|
|
var result = authService.Authenticate(new ClientAuthContext
|
|
{
|
|
Opts = new ClientOptions { Username = "alice", Password = "pass" },
|
|
Nonce = [],
|
|
});
|
|
|
|
result.ShouldNotBeNull();
|
|
result.Identity.ShouldBe("alice");
|
|
result.AccountName.ShouldBe("acct-a");
|
|
}
|
|
|
|
// Go: AuthService.Authenticate — user/password mismatch
|
|
[Fact]
|
|
public void AuthService_authenticate_user_password_failure()
|
|
{
|
|
var authService = AuthService.Build(new NatsOptions
|
|
{
|
|
Users = [new User { Username = "alice", Password = "pass" }],
|
|
});
|
|
|
|
var result = authService.Authenticate(new ClientAuthContext
|
|
{
|
|
Opts = new ClientOptions { Username = "alice", Password = "wrong" },
|
|
Nonce = [],
|
|
});
|
|
|
|
result.ShouldBeNull();
|
|
}
|
|
|
|
// Go: AuthService.Authenticate — no auth user fallback
|
|
[Fact]
|
|
public void AuthService_authenticate_no_auth_user_fallback()
|
|
{
|
|
var authService = AuthService.Build(new NatsOptions
|
|
{
|
|
Users =
|
|
[
|
|
new User { Username = "foo", Password = "pwd1", Account = "FOO" },
|
|
],
|
|
NoAuthUser = "foo",
|
|
});
|
|
|
|
// No credentials provided — should fall back to no_auth_user
|
|
var result = authService.Authenticate(new ClientAuthContext
|
|
{
|
|
Opts = new ClientOptions(),
|
|
Nonce = [],
|
|
});
|
|
|
|
result.ShouldNotBeNull();
|
|
result.Identity.ShouldBe("foo");
|
|
result.AccountName.ShouldBe("FOO");
|
|
}
|
|
|
|
// Go: AuthService.GenerateNonce — nonce generation
|
|
[Fact]
|
|
public void AuthService_generates_unique_nonces()
|
|
{
|
|
var authService = AuthService.Build(new NatsOptions
|
|
{
|
|
NKeys = [new NKeyUser { Nkey = "UABC" }],
|
|
});
|
|
|
|
var nonce1 = authService.GenerateNonce();
|
|
var nonce2 = authService.GenerateNonce();
|
|
|
|
nonce1.Length.ShouldBe(11);
|
|
nonce2.Length.ShouldBe(11);
|
|
// Extremely unlikely to be the same
|
|
nonce1.ShouldNotBe(nonce2);
|
|
}
|
|
|
|
// Go: AuthService.EncodeNonce — nonce encoding
|
|
[Fact]
|
|
public void AuthService_nonce_encoding_is_url_safe_base64()
|
|
{
|
|
var authService = AuthService.Build(new NatsOptions());
|
|
var nonce = new byte[] { 0xFF, 0xFE, 0xFD, 0xFC, 0xFB, 0xFA, 0xF9, 0xF8, 0xF7, 0xF6, 0xF5 };
|
|
|
|
var encoded = authService.EncodeNonce(nonce);
|
|
|
|
// Should not contain standard base64 padding or non-URL-safe characters
|
|
encoded.ShouldNotContain("=");
|
|
encoded.ShouldNotContain("+");
|
|
encoded.ShouldNotContain("/");
|
|
}
|
|
}
|