// Copyright 2012-2025 The NATS Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. using Shouldly; using ZB.MOM.NatsNet.Server; using ZB.MOM.NatsNet.Server.Auth; namespace ZB.MOM.NatsNet.Server.Tests.Auth; /// /// Tests for AuthHandler standalone functions. /// Mirrors Go auth_test.go and adds unit tests for validators. /// public class AuthHandlerTests { // ========================================================================= // IsBcrypt // ========================================================================= [Theory] [InlineData("$2a$10$abc123", true)] [InlineData("$2b$12$xyz", true)] [InlineData("$2x$04$foo", true)] [InlineData("$2y$10$bar", true)] [InlineData("$3a$10$abc", false)] [InlineData("plaintext", false)] [InlineData("", false)] [InlineData("$2a", false)] public void IsBcrypt_DetectsCorrectly(string password, bool expected) { AuthHandler.IsBcrypt(password).ShouldBe(expected); } // ========================================================================= // ComparePasswords // ========================================================================= [Fact] public void ComparePasswords_PlaintextMatch() { AuthHandler.ComparePasswords("secret", "secret").ShouldBeTrue(); } [Fact] public void ComparePasswords_PlaintextMismatch() { AuthHandler.ComparePasswords("secret", "wrong").ShouldBeFalse(); } [Fact] public void ComparePasswords_BcryptMatch() { var hash = BCrypt.Net.BCrypt.HashPassword("mypassword"); AuthHandler.ComparePasswords(hash, "mypassword").ShouldBeTrue(); } [Fact] public void ComparePasswords_BcryptMismatch() { var hash = BCrypt.Net.BCrypt.HashPassword("mypassword"); AuthHandler.ComparePasswords(hash, "wrongpassword").ShouldBeFalse(); } [Fact] public void ComparePasswords_EmptyPasswords_Match() { AuthHandler.ComparePasswords("", "").ShouldBeTrue(); } [Fact] public void ComparePasswords_DifferentLengths_Mismatch() { AuthHandler.ComparePasswords("short", "longpassword").ShouldBeFalse(); } // ========================================================================= // ValidateResponsePermissions // ========================================================================= [Fact] public void ValidateResponsePermissions_NullPermissions_NoOp() { AuthHandler.ValidateResponsePermissions(null); } [Fact] public void ValidateResponsePermissions_NullResponse_NoOp() { var perms = new Permissions(); AuthHandler.ValidateResponsePermissions(perms); perms.Publish.ShouldBeNull(); } [Fact] public void ValidateResponsePermissions_SetsDefaults() { var perms = new Permissions { Response = new ResponsePermission(), }; AuthHandler.ValidateResponsePermissions(perms); perms.Response.MaxMsgs.ShouldBe(ServerConstants.DefaultAllowResponseMaxMsgs); perms.Response.Expires.ShouldBe(ServerConstants.DefaultAllowResponseExpiration); perms.Publish.ShouldNotBeNull(); perms.Publish!.Allow.ShouldNotBeNull(); perms.Publish.Allow!.Count.ShouldBe(0); } [Fact] public void ValidateResponsePermissions_PreservesExistingValues() { var perms = new Permissions { Publish = new SubjectPermission { Allow = ["foo.>"] }, Response = new ResponsePermission { MaxMsgs = 10, Expires = TimeSpan.FromMinutes(5), }, }; AuthHandler.ValidateResponsePermissions(perms); perms.Response.MaxMsgs.ShouldBe(10); perms.Response.Expires.ShouldBe(TimeSpan.FromMinutes(5)); perms.Publish.Allow.ShouldBe(["foo.>"]); } // ========================================================================= // ValidateAllowedConnectionTypes // ========================================================================= [Fact] public void ValidateAllowedConnectionTypes_NullMap_ReturnsNull() { AuthHandler.ValidateAllowedConnectionTypes(null).ShouldBeNull(); } [Fact] public void ValidateAllowedConnectionTypes_ValidTypes_NoError() { var m = new HashSet { "STANDARD", "WEBSOCKET" }; AuthHandler.ValidateAllowedConnectionTypes(m).ShouldBeNull(); } [Fact] public void ValidateAllowedConnectionTypes_UnknownType_ReturnsError() { var m = new HashSet { "STANDARD", "someNewType" }; var err = AuthHandler.ValidateAllowedConnectionTypes(m); err.ShouldNotBeNull(); err!.Message.ShouldContain("connection type"); } [Fact] public void ValidateAllowedConnectionTypes_NormalizesToUppercase() { var m = new HashSet { "websocket", "mqtt" }; AuthHandler.ValidateAllowedConnectionTypes(m).ShouldBeNull(); m.ShouldContain("WEBSOCKET"); m.ShouldContain("MQTT"); m.ShouldNotContain("websocket"); m.ShouldNotContain("mqtt"); } [Fact] public void ValidateAllowedConnectionTypes_AllKnownTypes_NoError() { var m = new HashSet { "STANDARD", "WEBSOCKET", "LEAFNODE", "LEAFNODE_WS", "MQTT", "MQTT_WS", "IN_PROCESS", }; AuthHandler.ValidateAllowedConnectionTypes(m).ShouldBeNull(); } // ========================================================================= // ValidateNoAuthUser // ========================================================================= [Fact] public void ValidateNoAuthUser_EmptyNoAuthUser_NoError() { var opts = new ServerOptions(); AuthHandler.ValidateNoAuthUser(opts, "").ShouldBeNull(); } [Fact] public void ValidateNoAuthUser_WithTrustedOperators_ReturnsError() { var opts = new ServerOptions { TrustedOperators = [new object()], }; var err = AuthHandler.ValidateNoAuthUser(opts, "foo"); err.ShouldNotBeNull(); err!.Message.ShouldContain("Trusted Operator"); } [Fact] public void ValidateNoAuthUser_NoUsersOrNkeys_ReturnsError() { var opts = new ServerOptions(); var err = AuthHandler.ValidateNoAuthUser(opts, "foo"); err.ShouldNotBeNull(); err!.Message.ShouldContain("users/nkeys are not defined"); } [Fact] public void ValidateNoAuthUser_UserFound_NoError() { var opts = new ServerOptions { Users = [new User { Username = "foo" }], }; AuthHandler.ValidateNoAuthUser(opts, "foo").ShouldBeNull(); } [Fact] public void ValidateNoAuthUser_NkeyFound_NoError() { var opts = new ServerOptions { Nkeys = [new NkeyUser { Nkey = "NKEY1" }], }; AuthHandler.ValidateNoAuthUser(opts, "NKEY1").ShouldBeNull(); } [Fact] public void ValidateNoAuthUser_UserNotFound_ReturnsError() { var opts = new ServerOptions { Users = [new User { Username = "bar" }], }; var err = AuthHandler.ValidateNoAuthUser(opts, "foo"); err.ShouldNotBeNull(); err!.Message.ShouldContain("not present as user or nkey"); } // ========================================================================= // ValidateAuth // ========================================================================= [Fact] public void ValidateAuth_ValidConfig_NoError() { var opts = new ServerOptions { Users = [ new User { Username = "u1", AllowedConnectionTypes = new HashSet { "STANDARD" }, }, ], NoAuthUser = "u1", }; AuthHandler.ValidateAuth(opts).ShouldBeNull(); } [Fact] public void ValidateAuth_InvalidConnectionType_ReturnsError() { var opts = new ServerOptions { Users = [ new User { Username = "u1", AllowedConnectionTypes = new HashSet { "STANDARD", "BAD_TYPE" }, }, ], }; var err = AuthHandler.ValidateAuth(opts); err.ShouldNotBeNull(); err!.Message.ShouldContain("connection type"); } // ========================================================================= // DnsAltNameLabels + DnsAltNameMatches — Go test ID 148 // ========================================================================= [Theory] [InlineData("foo", new[] { "nats://FOO" }, true)] [InlineData("foo", new[] { "nats://.." }, false)] [InlineData("foo", new[] { "nats://." }, false)] [InlineData("Foo", new[] { "nats://foO" }, true)] [InlineData("FOO", new[] { "nats://foo" }, true)] [InlineData("foo1", new[] { "nats://bar" }, false)] [InlineData("multi", new[] { "nats://m", "nats://mu", "nats://mul", "nats://multi" }, true)] [InlineData("multi", new[] { "nats://multi", "nats://m", "nats://mu", "nats://mul" }, true)] [InlineData("foo.bar", new[] { "nats://foo", "nats://foo.bar.bar", "nats://foo.baz" }, false)] [InlineData("foo.Bar", new[] { "nats://foo", "nats://bar.foo", "nats://Foo.Bar" }, true)] [InlineData("foo.*", new[] { "nats://foo", "nats://bar.foo", "nats://Foo.Bar" }, false)] [InlineData("f*.bar", new[] { "nats://foo", "nats://bar.foo", "nats://Foo.Bar" }, false)] [InlineData("*.bar", new[] { "nats://foo.bar" }, true)] [InlineData("*", new[] { "nats://baz.bar", "nats://bar", "nats://z.y" }, true)] [InlineData("*", new[] { "nats://bar" }, true)] [InlineData("*", new[] { "nats://." }, false)] [InlineData("*", new[] { "nats://" }, true)] // NOTE: Go test cases {"*", ["*"], true} and {"bar.*", ["bar.*"], true} are omitted // because .NET's Uri class does not preserve '*' in hostnames the same way Go's url.Parse does. // Similarly, cases with leading dots like ".Y.local" and "..local" are omitted // because .NET's Uri normalizes those hostnames differently. [InlineData("*.Y-X-red-mgmt.default.svc", new[] { "nats://A.Y-X-red-mgmt.default.svc" }, true)] [InlineData("*.Y-X-green-mgmt.default.svc", new[] { "nats://A.Y-X-green-mgmt.default.svc" }, true)] [InlineData("*.Y-X-blue-mgmt.default.svc", new[] { "nats://A.Y-X-blue-mgmt.default.svc" }, true)] [InlineData("Y-X-red-mgmt", new[] { "nats://Y-X-red-mgmt" }, true)] [InlineData("Y-X-red-mgmt", new[] { "nats://X-X-red-mgmt" }, false)] [InlineData("Y-X-red-mgmt", new[] { "nats://Y-X-green-mgmt" }, false)] [InlineData("Y-X-red-mgmt", new[] { "nats://Y" }, false)] [InlineData("Y-X-red-mgmt", new[] { "nats://Y-X" }, false)] [InlineData("Y-X-red-mgmt", new[] { "nats://Y-X-red" }, false)] [InlineData("Y-X-red-mgmt", new[] { "nats://X-red-mgmt" }, false)] [InlineData("Y-X-green-mgmt", new[] { "nats://Y-X-green-mgmt" }, true)] [InlineData("Y-X-blue-mgmt", new[] { "nats://Y-X-blue-mgmt" }, true)] [InlineData("connect.Y.local", new[] { "nats://connect.Y.local" }, true)] [InlineData("gcp.Y.local", new[] { "nats://gcp.Y.local" }, true)] [InlineData("uswest1.gcp.Y.local", new[] { "nats://uswest1.gcp.Y.local" }, true)] public void DnsAltNameMatches_MatchesCorrectly(string altName, string[] urlStrings, bool expected) { var urls = urlStrings.Select(s => new Uri(s)).ToArray(); var labels = AuthHandler.DnsAltNameLabels(altName); AuthHandler.DnsAltNameMatches(labels, urls).ShouldBe(expected); } [Fact] public void DnsAltNameMatches_NullUrl_Skipped() { var labels = AuthHandler.DnsAltNameLabels("foo"); var urls = new Uri?[] { null, new Uri("nats://foo") }; AuthHandler.DnsAltNameMatches(labels, urls!).ShouldBeTrue(); } // ========================================================================= // WipeSlice // ========================================================================= [Fact] public void WipeSlice_FillsWithX() { var buf = new byte[] { 1, 2, 3, 4, 5 }; AuthHandler.WipeSlice(buf); buf.ShouldAllBe(b => b == (byte)'x'); } [Fact] public void WipeSlice_EmptyBuffer_NoOp() { var buf = Array.Empty(); AuthHandler.WipeSlice(buf); // should not throw } // ========================================================================= // ConnectionTypes known check // ========================================================================= [Theory] [InlineData("STANDARD", true)] [InlineData("WEBSOCKET", true)] [InlineData("LEAFNODE", true)] [InlineData("LEAFNODE_WS", true)] [InlineData("MQTT", true)] [InlineData("MQTT_WS", true)] [InlineData("IN_PROCESS", true)] [InlineData("UNKNOWN", false)] [InlineData("", false)] public void ConnectionTypes_IsKnown_DetectsCorrectly(string ct, bool expected) { AuthHandler.ConnectionTypes.IsKnown(ct).ShouldBe(expected); } // ========================================================================= // GetAuthErrClosedState — Go test ID 153 (T:153) // Mirrors the closed-state logic exercised by TestAuthProxyRequired. // (The full Go test is server-dependent; this covers the pure unit subset.) // ========================================================================= /// /// Mirrors the proxy-required branch of TestAuthProxyRequired (T:153). /// [Fact] // T:153 public void GetAuthErrClosedState_ProxyRequired_ReturnsProxyRequired() { var state = AuthHandler.GetAuthErrClosedState(new AuthProxyRequiredException()); state.ShouldBe(ClosedState.ProxyRequired); } [Fact] public void GetAuthErrClosedState_ProxyNotTrusted_ReturnsProxyNotTrusted() { var state = AuthHandler.GetAuthErrClosedState(new AuthProxyNotTrustedException()); state.ShouldBe(ClosedState.ProxyNotTrusted); } [Fact] public void GetAuthErrClosedState_OtherException_ReturnsAuthenticationViolation() { var state = AuthHandler.GetAuthErrClosedState(new InvalidOperationException("bad")); state.ShouldBe(ClosedState.AuthenticationViolation); } [Fact] public void GetAuthErrClosedState_NullException_ReturnsAuthenticationViolation() { var state = AuthHandler.GetAuthErrClosedState(null); state.ShouldBe(ClosedState.AuthenticationViolation); } // ========================================================================= // ValidateProxies // ========================================================================= [Fact] public void ValidateProxies_ProxyRequiredWithoutProxyProtocol_ReturnsError() { var opts = new ServerOptions { ProxyRequired = true, ProxyProtocol = false }; var err = AuthHandler.ValidateProxies(opts); err.ShouldNotBeNull(); err!.Message.ShouldContain("proxy_required"); } [Fact] public void ValidateProxies_ProxyRequiredWithProxyProtocol_ReturnsNull() { var opts = new ServerOptions { ProxyRequired = true, ProxyProtocol = true }; AuthHandler.ValidateProxies(opts).ShouldBeNull(); } [Fact] public void ValidateProxies_NeitherSet_ReturnsNull() { var opts = new ServerOptions(); AuthHandler.ValidateProxies(opts).ShouldBeNull(); } }