From 4f41253cdc96680adcd84c9b441df7578aceb246 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Mon, 15 Jun 2026 12:47:12 +0300 Subject: [PATCH 1/2] Add December route cases --- DorkNet.sln | 15 ++ .../DorkNet.ApiRouteCoverage.csproj | 14 ++ tools/DorkNet.ApiRouteCoverage/Program.cs | 204 ++++++++++++++++++ 3 files changed, 233 insertions(+) create mode 100644 tools/DorkNet.ApiRouteCoverage/DorkNet.ApiRouteCoverage.csproj create mode 100644 tools/DorkNet.ApiRouteCoverage/Program.cs diff --git a/DorkNet.sln b/DorkNet.sln index 01601e1..b8818b0 100644 --- a/DorkNet.sln +++ b/DorkNet.sln @@ -13,6 +13,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MigrateSqliteToPostgres", " EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.AdminMobile", "DorkNet.AdminMobile\DorkNet.AdminMobile.csproj", "{C2265F00-DFC5-4352-A94C-B887E3C0E5CA}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.ApiRouteCoverage", "tools\DorkNet.ApiRouteCoverage\DorkNet.ApiRouteCoverage.csproj", "{AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -71,11 +73,24 @@ Global {C2265F00-DFC5-4352-A94C-B887E3C0E5CA}.Release|x64.Build.0 = Release|Any CPU {C2265F00-DFC5-4352-A94C-B887E3C0E5CA}.Release|x86.ActiveCfg = Release|Any CPU {C2265F00-DFC5-4352-A94C-B887E3C0E5CA}.Release|x86.Build.0 = Release|Any CPU + {AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}.Debug|x64.ActiveCfg = Debug|Any CPU + {AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}.Debug|x64.Build.0 = Debug|Any CPU + {AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}.Debug|x86.ActiveCfg = Debug|Any CPU + {AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}.Debug|x86.Build.0 = Debug|Any CPU + {AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}.Release|Any CPU.Build.0 = Release|Any CPU + {AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}.Release|x64.ActiveCfg = Release|Any CPU + {AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}.Release|x64.Build.0 = Release|Any CPU + {AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}.Release|x86.ActiveCfg = Release|Any CPU + {AB8D33A2-8F4C-4E10-BD25-51343D20F7ED}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {70C60098-1B11-4C13-943B-A8A056429EAE} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84} + {AB8D33A2-8F4C-4E10-BD25-51343D20F7ED} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84} EndGlobalSection EndGlobal diff --git a/tools/DorkNet.ApiRouteCoverage/DorkNet.ApiRouteCoverage.csproj b/tools/DorkNet.ApiRouteCoverage/DorkNet.ApiRouteCoverage.csproj new file mode 100644 index 0000000..10a76b3 --- /dev/null +++ b/tools/DorkNet.ApiRouteCoverage/DorkNet.ApiRouteCoverage.csproj @@ -0,0 +1,14 @@ + + + + Exe + net9.0 + enable + enable + + + + + + + diff --git a/tools/DorkNet.ApiRouteCoverage/Program.cs b/tools/DorkNet.ApiRouteCoverage/Program.cs new file mode 100644 index 0000000..154ba34 --- /dev/null +++ b/tools/DorkNet.ApiRouteCoverage/Program.cs @@ -0,0 +1,204 @@ +using System.Reflection; +using System.Runtime.Loader; +using System.Text.RegularExpressions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; + +var serverAssembly = LoadServerAssembly(args); +var routes = DiscoverRoutes(serverAssembly).OrderBy(r => r.Method).ThenBy(r => r.Template).ToArray(); +var failures = new List(); + +if (routes.Length == 0) + failures.Add("No HTTP controller routes were discovered."); + +foreach (var route in routes) +{ + if (string.IsNullOrWhiteSpace(route.Template)) + failures.Add($"{route.Source}: empty route template"); + + if (route.SamplePath.Contains('{') || route.SamplePath.Contains('}')) + failures.Add($"{route.Source}: unresolved route parameter in {route.SamplePath}"); + + if (route.SamplePath.Contains('[') || route.SamplePath.Contains(']')) + failures.Add($"{route.Source}: unresolved token in {route.SamplePath}"); + + if (!route.SamplePath.StartsWith('/')) + failures.Add($"{route.Source}: sample path must be absolute: {route.SamplePath}"); +} + +Console.WriteLine($"Discovered {routes.Length} HTTP route test cases."); +foreach (var route in routes) + Console.WriteLine($"{route.Method,-6} {route.SamplePath,-72} {route.Source}"); + +if (failures.Count == 0) + return 0; + +Console.Error.WriteLine(); +Console.Error.WriteLine("Route coverage failed:"); +foreach (var failure in failures) + Console.Error.WriteLine($"- {failure}"); +return 1; + +static Assembly LoadServerAssembly(string[] args) +{ + if (args.Length > 0) + return LoadAssemblyFromPath(Path.GetFullPath(args[0])); + + var repositoryRoot = FindRepositoryRoot(); + var candidates = new[] + { + Path.Combine(repositoryRoot, "DorkNet.Server", "bin", "Debug", "net9.0", "DorkNet.Server.dll"), + Path.Combine(repositoryRoot, "DorkNet.Server", "bin", "Release", "net9.0", "DorkNet.Server.dll") + }; + + var serverAssemblyPath = candidates.FirstOrDefault(File.Exists); + if (serverAssemblyPath is null) + { + throw new FileNotFoundException( + "Could not find a built DorkNet.Server assembly. Build DorkNet.Server first or pass the DLL path as the first argument.", + string.Join(Environment.NewLine, candidates)); + } + + return LoadAssemblyFromPath(serverAssemblyPath); +} + +static Assembly LoadAssemblyFromPath(string assemblyPath) +{ + var assemblyDirectory = Path.GetDirectoryName(assemblyPath) + ?? throw new DirectoryNotFoundException($"Could not resolve assembly directory for {assemblyPath}."); + + AssemblyLoadContext.Default.Resolving += (_, assemblyName) => + { + var dependencyPath = Path.Combine(assemblyDirectory, assemblyName.Name + ".dll"); + return File.Exists(dependencyPath) + ? AssemblyLoadContext.Default.LoadFromAssemblyPath(dependencyPath) + : null; + }; + + return AssemblyLoadContext.Default.LoadFromAssemblyPath(assemblyPath); +} + +static string FindRepositoryRoot() +{ + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current is not null) + { + if (File.Exists(Path.Combine(current.FullName, "DorkNet.Server", "DorkNet.Server.csproj"))) + return current.FullName; + + current = current.Parent; + } + + throw new DirectoryNotFoundException("Could not locate the repository root from the route coverage tool output path."); +} + +static IEnumerable DiscoverRoutes(Assembly serverAssembly) +{ + var controllerBaseType = typeof(ControllerBase); + foreach (var controller in serverAssembly.GetTypes() + .Where(t => !t.IsAbstract && controllerBaseType.IsAssignableFrom(t))) + { + var controllerName = ControllerName(controller); + var controllerRouteTemplates = controller.GetCustomAttributes(inherit: true) + .OfType() + .Select(a => a.Template) + .Where(t => !string.IsNullOrWhiteSpace(t)) + .Cast() + .DefaultIfEmpty("") + .ToArray(); + + foreach (var method in controller.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly)) + { + var httpAttrs = method.GetCustomAttributes(inherit: true) + .OfType() + .ToArray(); + if (httpAttrs.Length == 0) + continue; + + foreach (var attr in httpAttrs) + { + var attrMethods = attr.HttpMethods.Any() + ? attr.HttpMethods + : new[] { "ANY" }; + var actionName = method.Name; + foreach (var httpMethod in attrMethods) + { + foreach (var controllerTemplate in controllerRouteTemplates) + { + var template = CombineTemplates(controllerTemplate, attr.Template); + template = ReplaceRouteTokens(template, controllerName, actionName); + yield return new RouteCase( + httpMethod.ToUpperInvariant(), + template, + ToSamplePath(template), + $"{controller.FullName}.{method.Name}"); + } + } + } + } + } +} + +static string ControllerName(Type controller) +{ + const string suffix = "Controller"; + return controller.Name.EndsWith(suffix, StringComparison.Ordinal) + ? controller.Name[..^suffix.Length] + : controller.Name; +} + +static string CombineTemplates(string? controllerTemplate, string? actionTemplate) +{ + controllerTemplate ??= ""; + actionTemplate ??= ""; + + if (actionTemplate.StartsWith("~/", StringComparison.Ordinal)) + return "/" + actionTemplate[2..].TrimStart('/'); + + if (actionTemplate.StartsWith("/", StringComparison.Ordinal)) + return "/" + actionTemplate.TrimStart('/'); + + var left = controllerTemplate.Trim('/'); + var right = actionTemplate.Trim('/'); + + if (string.IsNullOrEmpty(left) && string.IsNullOrEmpty(right)) + return "/"; + if (string.IsNullOrEmpty(left)) + return "/" + right; + if (string.IsNullOrEmpty(right)) + return "/" + left; + return "/" + left + "/" + right; +} + +static string ReplaceRouteTokens(string template, string controllerName, string actionName) +{ + return template + .Replace("[controller]", controllerName, StringComparison.OrdinalIgnoreCase) + .Replace("[action]", actionName, StringComparison.OrdinalIgnoreCase); +} + +static string ToSamplePath(string template) +{ + var path = RouteParameterRegex().Replace(template, match => + { + var constraint = match.Groups["constraint"].Value.ToLowerInvariant(); + if (constraint.Contains("long") || constraint.Contains("int")) + return "1"; + if (constraint.Contains("bool")) + return "true"; + if (constraint.Contains("guid")) + return "00000000-0000-0000-0000-000000000001"; + return "sample"; + }); + + path = Regex.Replace(path, "/+", "/"); + return path.StartsWith('/') ? path : "/" + path; +} + +internal sealed record RouteCase(string Method, string Template, string SamplePath, string Source); + +partial class Program +{ + [GeneratedRegex(@"\{[*]?(?[^}:=\?]+)(?:[^}=\?]+)?(?\?)?(?=[^}]+)?\}")] + private static partial Regex RouteParameterRegex(); +} From 1b9afcd0c6bde28b1a2f8fccc18ea8846ccde7f3 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:04:45 +0300 Subject: [PATCH 2/2] Harden December route cases --- tools/DorkNet.ApiRouteCoverage/Program.cs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/tools/DorkNet.ApiRouteCoverage/Program.cs b/tools/DorkNet.ApiRouteCoverage/Program.cs index 154ba34..5710454 100644 --- a/tools/DorkNet.ApiRouteCoverage/Program.cs +++ b/tools/DorkNet.ApiRouteCoverage/Program.cs @@ -5,6 +5,10 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Routing; var serverAssembly = LoadServerAssembly(args); +var serverAssemblyWriteTime = File.GetLastWriteTimeUtc(serverAssembly.Location); +Console.WriteLine($"Using server assembly: {serverAssembly.Location}"); +Console.WriteLine($"Server assembly timestamp (UTC): {serverAssemblyWriteTime:O}"); + var routes = DiscoverRoutes(serverAssembly).OrderBy(r => r.Method).ThenBy(r => r.Template).ToArray(); var failures = new List(); @@ -51,15 +55,23 @@ static Assembly LoadServerAssembly(string[] args) Path.Combine(repositoryRoot, "DorkNet.Server", "bin", "Release", "net9.0", "DorkNet.Server.dll") }; - var serverAssemblyPath = candidates.FirstOrDefault(File.Exists); - if (serverAssemblyPath is null) + var existingCandidates = candidates.Where(File.Exists).ToArray(); + if (existingCandidates.Length == 0) { throw new FileNotFoundException( "Could not find a built DorkNet.Server assembly. Build DorkNet.Server first or pass the DLL path as the first argument.", string.Join(Environment.NewLine, candidates)); } - return LoadAssemblyFromPath(serverAssemblyPath); + if (existingCandidates.Length > 1) + { + throw new InvalidOperationException( + "Multiple DorkNet.Server assemblies were found. Pass the DLL path as the first argument so route coverage validates the intended build." + + Environment.NewLine + + string.Join(Environment.NewLine, existingCandidates)); + } + + return LoadAssemblyFromPath(existingCandidates[0]); } static Assembly LoadAssemblyFromPath(string assemblyPath)