|
| 1 | +namespace Trellis.ServiceLevelIndicators.Asp.Tests; |
| 2 | + |
| 3 | +using System.Diagnostics.Metrics; |
| 4 | +using System.Net; |
| 5 | +using System.Reflection; |
| 6 | +using System.Text.Json; |
| 7 | +using FluentAssertions; |
| 8 | +using Microsoft.AspNetCore.Builder; |
| 9 | +using Microsoft.AspNetCore.Hosting; |
| 10 | +using Microsoft.AspNetCore.Http; |
| 11 | +using Microsoft.AspNetCore.Mvc; |
| 12 | +using Microsoft.AspNetCore.Mvc.ApplicationModels; |
| 13 | +using Microsoft.AspNetCore.TestHost; |
| 14 | +using Microsoft.Extensions.DependencyInjection; |
| 15 | +using Microsoft.Extensions.Hosting; |
| 16 | +using Microsoft.Extensions.Options; |
| 17 | + |
| 18 | +/// <summary> |
| 19 | +/// Regression tests covering interop between <see cref="ServiceLevelIndicatorServiceCollectionExtensions.AddMvc"/> |
| 20 | +/// and the ASP.NET Core <c>IProblemDetailsService</c> pipeline registered via <c>AddProblemDetails()</c>. |
| 21 | +/// |
| 22 | +/// Historical bug: <c>AddMvc()</c> previously called <c>services.AddMvcCore(...)</c> just to register a single |
| 23 | +/// MVC convention. Re-invoking the MVC services pipeline after the host had already called |
| 24 | +/// <c>AddControllers()</c> + <c>AddProblemDetails()</c> + <c>AddApiVersioning()...AddOpenApi()</c> |
| 25 | +/// caused the <c>IProblemDetailsService</c> writer to drop the runtime-typed <c>Errors</c> dictionary |
| 26 | +/// from <see cref="HttpValidationProblemDetails"/> responses (the writer fell back to the static base |
| 27 | +/// <see cref="ProblemDetails"/> type for serialization). Extensions on the base type (<c>traceId</c>, custom |
| 28 | +/// keys) survived; the validation <c>errors</c> dictionary did not. |
| 29 | +/// |
| 30 | +/// The fix replaces <c>AddMvcCore(...)</c> with <c>Configure<MvcOptions>(...)</c>, which registers the |
| 31 | +/// convention without re-invoking the MVC services pipeline. |
| 32 | +/// </summary> |
| 33 | +public class ProblemDetailsInteropTests |
| 34 | +{ |
| 35 | + [Fact] |
| 36 | + public void AddMvc_registers_ServiceLevelIndicatorConvention_without_calling_AddMvcCore() |
| 37 | + { |
| 38 | + // Baseline: capture the set of services AddMvcCore would have introduced. |
| 39 | + var withMvcCore = new ServiceCollection(); |
| 40 | + withMvcCore.AddMvcCore(); |
| 41 | + var mvcCoreOnlyTypes = withMvcCore |
| 42 | + .Select(d => d.ServiceType.FullName) |
| 43 | + .Where(name => name is not null) |
| 44 | + .ToHashSet(); |
| 45 | + |
| 46 | + // Subject: a service collection with ONLY AddServiceLevelIndicator().AddMvc(). |
| 47 | + using var meter = new Meter(nameof(ProblemDetailsInteropTests)); |
| 48 | + var services = new ServiceCollection(); |
| 49 | + services.AddOptions(); |
| 50 | + services.AddServiceLevelIndicator(options => |
| 51 | + { |
| 52 | + options.Meter = meter; |
| 53 | + options.CustomerResourceId = "TestCustomerResourceId"; |
| 54 | + options.LocationId = ServiceLevelIndicator.CreateLocationId("public", "West US 3"); |
| 55 | + }).AddMvc(); |
| 56 | + |
| 57 | + // The convention must be registered (functional check). MvcOptions.Conventions is |
| 58 | + // IList<IApplicationModelConvention>; ASP.NET wraps an IParameterModelConvention in an |
| 59 | + // internal adapter when added, so we can't pattern-match the stored instance directly. |
| 60 | + // Instead, inspect each convention's fields for a wrapped ServiceLevelIndicatorConvention. |
| 61 | + using var provider = services.BuildServiceProvider(); |
| 62 | + var mvcOptions = provider.GetRequiredService<IOptions<MvcOptions>>().Value; |
| 63 | + mvcOptions.Conventions.Should().Contain( |
| 64 | + convention => WrapsServiceLevelIndicatorConvention(convention), |
| 65 | + "AddMvc() must register the ServiceLevelIndicatorConvention."); |
| 66 | + |
| 67 | + // AddMvc() must NOT pull in the rest of MvcCore's service registrations. If any MvcCore-only |
| 68 | + // service types appear in our subject collection, AddMvc() is calling AddMvcCore() under the |
| 69 | + // hood and will interfere with the host's already-configured MVC + ProblemDetails pipeline. |
| 70 | + var subjectTypes = services |
| 71 | + .Select(d => d.ServiceType.FullName) |
| 72 | + .Where(name => name is not null) |
| 73 | + .ToHashSet(); |
| 74 | + |
| 75 | + var leakedMvcCoreServices = subjectTypes |
| 76 | + .Intersect(mvcCoreOnlyTypes) |
| 77 | + .Where(t => t!.StartsWith("Microsoft.AspNetCore.Mvc", StringComparison.Ordinal)) |
| 78 | + .ToList(); |
| 79 | + |
| 80 | + leakedMvcCoreServices.Should().BeEmpty( |
| 81 | + "AddMvc() must register only the convention, not invoke AddMvcCore(). " + |
| 82 | + "Re-invoking the MVC services pipeline interferes with IProblemDetailsService " + |
| 83 | + "polymorphic serialization of HttpValidationProblemDetails."); |
| 84 | + |
| 85 | + // Targeted regression check: AddMvcCore() registers MVC's IProblemDetailsWriter |
| 86 | + // (Microsoft.AspNetCore.Http namespace, so missed by the prefix filter above). That writer |
| 87 | + // is the exact service whose stale registration caused the original 'errors'-stripping bug. |
| 88 | + services.Should().NotContain( |
| 89 | + d => d.ServiceType.FullName == "Microsoft.AspNetCore.Http.IProblemDetailsWriter", |
| 90 | + "AddMvc() must not introduce IProblemDetailsWriter — that's what caused the 422 'errors' dict to be dropped."); |
| 91 | + } |
| 92 | + |
| 93 | + [Fact] |
| 94 | + public async Task AddMvc_validation_problem_includes_errors_when_written_via_ProblemDetailsService() |
| 95 | + { |
| 96 | + using var meter = new Meter(nameof(ProblemDetailsInteropTests)); |
| 97 | + using var host = await CreateHost(meter); |
| 98 | + |
| 99 | + var ct = TestContext.Current.CancellationToken; |
| 100 | + var client = host.GetTestClient(); |
| 101 | + var response = await client.PostAsync("/problem/validate", content: null, ct); |
| 102 | + |
| 103 | + response.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); |
| 104 | + var body = await response.Content.ReadAsStringAsync(ct); |
| 105 | + |
| 106 | + using var doc = JsonDocument.Parse(body); |
| 107 | + var root = doc.RootElement; |
| 108 | + |
| 109 | + root.TryGetProperty("status", out var status).Should().BeTrue(); |
| 110 | + status.GetInt32().Should().Be(StatusCodes.Status422UnprocessableEntity); |
| 111 | + |
| 112 | + root.TryGetProperty("errors", out var errors).Should().BeTrue( |
| 113 | + $"Validation 'errors' dictionary must round-trip through IProblemDetailsService. Body: {body}"); |
| 114 | + errors.ValueKind.Should().Be(JsonValueKind.Object); |
| 115 | + errors.TryGetProperty("name", out _).Should().BeTrue($"Body: {body}"); |
| 116 | + } |
| 117 | + |
| 118 | + private static async Task<IHost> CreateHost(Meter meter) => |
| 119 | + await new HostBuilder() |
| 120 | + .ConfigureWebHost(webBuilder => webBuilder |
| 121 | + .UseTestServer() |
| 122 | + .ConfigureServices(services => |
| 123 | + { |
| 124 | + services.AddProblemDetails(); |
| 125 | + services.AddControllers(); |
| 126 | + services.AddServiceLevelIndicator(options => |
| 127 | + { |
| 128 | + options.Meter = meter; |
| 129 | + options.CustomerResourceId = "TestCustomerResourceId"; |
| 130 | + options.LocationId = ServiceLevelIndicator.CreateLocationId("public", "West US 3"); |
| 131 | + }).AddMvc(); |
| 132 | + }) |
| 133 | + .Configure(app => app |
| 134 | + .UseRouting() |
| 135 | + .UseServiceLevelIndicator() |
| 136 | + .UseEndpoints(endpoints => endpoints.MapControllers()))) |
| 137 | + .StartAsync(); |
| 138 | + |
| 139 | + private static bool WrapsServiceLevelIndicatorConvention(IApplicationModelConvention convention) |
| 140 | + { |
| 141 | + if ((object)convention is ServiceLevelIndicatorConvention) |
| 142 | + return true; |
| 143 | + |
| 144 | + // ASP.NET wraps non-IApplicationModelConvention conventions (e.g. IParameterModelConvention) |
| 145 | + // in an internal adapter that holds the inner convention in a private field. |
| 146 | + const BindingFlags bindingFlags = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public; |
| 147 | + return convention.GetType() |
| 148 | + .GetFields(bindingFlags) |
| 149 | + .Any(f => f.GetValue(convention) is ServiceLevelIndicatorConvention); |
| 150 | + } |
| 151 | +} |
| 152 | + |
| 153 | +[ApiController] |
| 154 | +[Route("problem")] |
| 155 | +public sealed class ProblemDetailsTestController : ControllerBase |
| 156 | +{ |
| 157 | + // Mirrors what frameworks like Trellis.Asp's ResponseFailureWriter do: invoke |
| 158 | + // Results.ValidationProblem(...).ExecuteAsync(httpContext), which writes via IProblemDetailsService. |
| 159 | + [HttpPost("validate")] |
| 160 | + public async Task Validate() |
| 161 | + { |
| 162 | + var errors = new Dictionary<string, string[]> { ["name"] = ["Name is required."] }; |
| 163 | + var result = Results.ValidationProblem(errors, statusCode: StatusCodes.Status422UnprocessableEntity); |
| 164 | + await result.ExecuteAsync(HttpContext); |
| 165 | + } |
| 166 | +} |
0 commit comments