feat(ldap): LdapEndpoint + FallbackServers entry parser

This commit is contained in:
Joseph Doherty
2026-08-10 16:32:27 -04:00
parent 0c1702acbc
commit 5fbf53601c
2 changed files with 95 additions and 0 deletions
@@ -0,0 +1,60 @@
namespace ZB.MOM.WW.Auth.Ldap.Internal;
/// <summary>A single LDAP server endpoint resolved from configuration.</summary>
internal sealed record LdapEndpoint(string Host, int Port)
{
/// <summary>Renders the endpoint as <c>host:port</c> — the form used in failover log messages.</summary>
public override string ToString() => $"{Host}:{Port}";
}
/// <summary>
/// Parses <c>FallbackServers</c> entries: <c>"host"</c> (adopts the caller's default port)
/// or <c>"host:port"</c>. IPv6 literals are not supported — use a DNS name.
/// </summary>
internal static class LdapEndpointParser
{
/// <summary>
/// Attempts to parse a single <c>FallbackServers</c> entry.
/// </summary>
/// <param name="entry">The configured entry, <c>"host"</c> or <c>"host:port"</c>. Surrounding whitespace is tolerated.</param>
/// <param name="defaultPort">Port adopted when the entry carries no explicit one.</param>
/// <param name="endpoint">The parsed endpoint on success; undefined otherwise.</param>
/// <param name="error">A human-readable reason on failure; empty on success.</param>
/// <returns><see langword="true"/> when the entry parsed.</returns>
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;
}
}
@@ -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());
}