Files
Radicade/ServerTests/RouteAmbiguityTests.cs
2026-10-16 23:33:13 -07:00

108 lines
4.4 KiB
C#

using System.Reflection;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using Xunit;
namespace RadicadeServer.Tests;
/// <summary>
/// Two endpoints that claim the same method + path make ASP.NET throw
/// <c>AmbiguousMatchException</c> (HTTP 500) for EVERY request to that path, before any
/// authorization or handler code runs. That is how a club "set minimum level" or "remove member"
/// click became an unhandled exception in the server log instead of an answer.
///
/// The route table is therefore asserted directly: a controller-level test cannot see two
/// endpoints fighting over one URL, because it calls the handler it wants by name.
///
/// The classic way this happens here is an action carrying both the class-relative template
/// (<c>"{clubId}/minlevel"</c> under <c>[Route("clubs/club")]</c>) and an absolute
/// <c>"~/clubs/club/{clubId}/minlevel"</c> copy of it: the same path, registered twice.
/// </summary>
public class RouteAmbiguityTests
{
/// <summary>
/// <c>{id}</c> and <c>{id:long}</c> both match a numeric segment with the same route
/// precedence, so ASP.NET reports ambiguity for that pair as well - compare on the
/// constraint-free form to catch both kinds.
/// </summary>
private static string Unconstrained(string template)
{
var result = new System.Text.StringBuilder(template.Length);
var index = 0;
while (true)
{
var start = template.IndexOf('{', index);
if (start < 0)
{
result.Append(template, index, template.Length - index);
return result.ToString();
}
var end = template.IndexOf('}', start);
if (end < 0)
{
result.Append(template, index, template.Length - index);
return result.ToString();
}
var name = template.Substring(start + 1, end - start - 1).TrimStart('*');
var cut = name.IndexOfAny(new[] { ':', '(' });
if (cut >= 0) name = name.Substring(0, cut);
result.Append(template, index, start - index).Append('{').Append(name).Append('}');
index = end + 1;
}
}
private static IEnumerable<(string Verb, string Template, string Owner)> Routes()
{
var assembly = typeof(RadicadeServer.Clubs.ClubsAPI).Assembly;
foreach (var type in assembly.GetTypes())
{
if (!typeof(ControllerBase).IsAssignableFrom(type) || type.IsAbstract) continue;
var classRoutes = type.GetCustomAttributes<RouteAttribute>(inherit: false)
.Select(attribute => (attribute.Template ?? string.Empty).Trim('/'))
.ToArray();
foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly))
{
foreach (var attribute in method.GetCustomAttributes<HttpMethodAttribute>(inherit: false))
{
var template = attribute.Template ?? string.Empty;
if (template.StartsWith("~/", StringComparison.Ordinal))
{
foreach (var verb in attribute.HttpMethods)
yield return (verb, template.Substring(2), $"{type.Name}.{method.Name}");
continue;
}
var prefixes = classRoutes.Length == 0 ? new[] { string.Empty } : classRoutes;
foreach (var prefix in prefixes)
{
var combined = prefix.Length == 0 ? template : $"{prefix}/{template.TrimStart('/')}";
foreach (var verb in attribute.HttpMethods)
yield return (verb, combined, $"{type.Name}.{method.Name}");
}
}
}
}
}
[Fact]
public void NoTwoEndpointsClaimTheSameMethodAndPath()
{
var duplicates = Routes()
.GroupBy(route => (route.Verb, Path: Unconstrained(route.Template)))
.Where(group => group.Select(route => (route.Owner, route.Template)).Distinct().Count() > 1)
.Select(group => $"{group.Key.Verb} /{group.Key.Path} is claimed by " +
string.Join(" and ", group.Select(route => $"{route.Owner} [{route.Template}]").Distinct()))
.ToArray();
Assert.Empty(duplicates);
}
}