From 5fbf53601c0cb2dd37725bb6222ff1cd13aa55ec Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 10 Aug 2026 16:32:27 -0400 Subject: [PATCH] feat(ldap): LdapEndpoint + FallbackServers entry parser --- .../Internal/LdapEndpoint.cs | 60 +++++++++++++++++++ .../LdapEndpointParserTests.cs | 35 +++++++++++ 2 files changed, 95 insertions(+) create mode 100644 ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/Internal/LdapEndpoint.cs create mode 100644 ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapEndpointParserTests.cs diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/Internal/LdapEndpoint.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/Internal/LdapEndpoint.cs new file mode 100644 index 0000000..b70a524 --- /dev/null +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/Internal/LdapEndpoint.cs @@ -0,0 +1,60 @@ +namespace ZB.MOM.WW.Auth.Ldap.Internal; + +/// A single LDAP server endpoint resolved from configuration. +internal sealed record LdapEndpoint(string Host, int Port) +{ + /// Renders the endpoint as host:port — the form used in failover log messages. + public override string ToString() => $"{Host}:{Port}"; +} + +/// +/// Parses FallbackServers entries: "host" (adopts the caller's default port) +/// or "host:port". IPv6 literals are not supported — use a DNS name. +/// +internal static class LdapEndpointParser +{ + /// + /// Attempts to parse a single FallbackServers entry. + /// + /// The configured entry, "host" or "host:port". Surrounding whitespace is tolerated. + /// Port adopted when the entry carries no explicit one. + /// The parsed endpoint on success; undefined otherwise. + /// A human-readable reason on failure; empty on success. + /// when the entry parsed. + public static bool TryParse(string entry, int defaultPort, out LdapEndpoint endpoint, out string error) + { + endpoint = null!; + error = ""; + + if (string.IsNullOrWhiteSpace(entry)) + { + error = "entry is empty or whitespace"; + return false; + } + + var parts = entry.Trim().Split(':'); + if (parts.Length > 2) + { + error = $"entry \"{entry.Trim()}\" contains more than one ':' " + + "(IPv6 literals are not supported — use a DNS name)"; + return false; + } + + var host = parts[0].Trim(); + if (host.Length == 0) + { + error = $"entry \"{entry.Trim()}\" has an empty host"; + return false; + } + + var port = defaultPort; + if (parts.Length == 2 && (!int.TryParse(parts[1].Trim(), out port) || port is < 1 or > 65535)) + { + error = $"entry \"{entry.Trim()}\" has an invalid port (must be 1–65535)"; + return false; + } + + endpoint = new LdapEndpoint(host, port); + return true; + } +} diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapEndpointParserTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapEndpointParserTests.cs new file mode 100644 index 0000000..67aeedf --- /dev/null +++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapEndpointParserTests.cs @@ -0,0 +1,35 @@ +using ZB.MOM.WW.Auth.Ldap.Internal; + +namespace ZB.MOM.WW.Auth.Ldap.Tests; + +public class LdapEndpointParserTests +{ + [Theory] + [InlineData("dc2.example.com", 636, "dc2.example.com", 636)] // bare host -> default port + [InlineData("dc2.example.com:3894", 636, "dc2.example.com", 3894)] // explicit port wins + [InlineData(" dc2.example.com : 3894 ", 636, "dc2.example.com", 3894)] // whitespace tolerated + public void TryParse_ValidEntry_Succeeds(string entry, int defaultPort, string host, int port) + { + Assert.True(LdapEndpointParser.TryParse(entry, defaultPort, out var ep, out _)); + Assert.Equal(host, ep.Host); + Assert.Equal(port, ep.Port); + } + + [Theory] + [InlineData("")] // empty + [InlineData(" ")] // whitespace + [InlineData(":636")] // empty host + [InlineData("dc2:0")] // port below range + [InlineData("dc2:65536")] // port above range + [InlineData("dc2:abc")] // non-numeric port + [InlineData("2001:db8::1:636")] // IPv6 literal (unsupported, >1 colon) + public void TryParse_InvalidEntry_Fails(string entry) + { + Assert.False(LdapEndpointParser.TryParse(entry, 636, out _, out var error)); + Assert.NotEmpty(error); + } + + [Fact] + public void Endpoint_ToString_IsHostColonPort() => + Assert.Equal("dc2:3894", new LdapEndpoint("dc2", 3894).ToString()); +}