File: Matching\ContentEncodingNegotiationMatcherPolicyIntegrationTestBase.cs
Web Access
Project: src\src\Http\Routing\test\UnitTests\Microsoft.AspNetCore.Routing.Tests.csproj (Microsoft.AspNetCore.Routing.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing.Patterns;
using Microsoft.Extensions.DependencyInjection;
 
namespace Microsoft.AspNetCore.Routing.Matching;
 
// End-to-end tests for the content encoding negotiation matching functionality
public abstract class ContentEncodingNegotiationMatcherPolicyIntegrationTestBase
{
    protected abstract bool HasDynamicMetadata { get; }
 
    [Fact]
    public async Task Match_ContentEncoding_SelectsEndpointWithMatchingEncoding()
    {
        // Arrange
        var gzipEndpoint = CreateEndpoint("/hello", contentEncoding: "gzip");
        var identityEndpoint = CreateEndpoint("/hello");
 
        var matcher = CreateMatcher(gzipEndpoint, identityEndpoint);
        var httpContext = CreateContext("/hello", "gzip");
 
        // Act
        await matcher.MatchAsync(httpContext);
 
        // Assert
        MatcherAssert.AssertMatch(httpContext, gzipEndpoint);
    }
 
    [Fact]
    public async Task Match_ContentEncoding_SelectsIdentityEndpoint_WhenEncodingNotInAcceptHeader()
    {
        // Arrange
        var gzipEndpoint = CreateEndpoint("/hello", contentEncoding: "gzip");
        var identityEndpoint = CreateEndpoint("/hello");
 
        var matcher = CreateMatcher(gzipEndpoint, identityEndpoint);
        var httpContext = CreateContext("/hello", "br");
 
        // Act
        await matcher.MatchAsync(httpContext);
 
        // Assert
        MatcherAssert.AssertMatch(httpContext, identityEndpoint);
    }
 
    [Fact]
    public async Task Match_ContentEncoding_SelectsHigherAcceptQuality()
    {
        // Arrange
        var gzipEndpoint = CreateEndpoint("/hello", contentEncoding: "gzip");
        var brEndpoint = CreateEndpoint("/hello", contentEncoding: "br");
 
        var matcher = CreateMatcher(gzipEndpoint, brEndpoint);
        var httpContext = CreateContext("/hello", "gzip;q=0.5, br;q=1.0");
 
        // Act
        await matcher.MatchAsync(httpContext);
 
        // Assert
        MatcherAssert.AssertMatch(httpContext, brEndpoint);
    }
 
    [Fact]
    public async Task Match_ContentEncoding_SelectsHigherEndpointQuality_WhenAcceptQualityEqual()
    {
        // Arrange
        var gzipEndpoint = CreateEndpoint("/hello", contentEncoding: "gzip", quality: 0.9);
        var brEndpoint = CreateEndpoint("/hello", contentEncoding: "br", quality: 0.5);
 
        var matcher = CreateMatcher(gzipEndpoint, brEndpoint);
        var httpContext = CreateContext("/hello", "gzip, br");
 
        // Act
        await matcher.MatchAsync(httpContext);
 
        // Assert
        MatcherAssert.AssertMatch(httpContext, gzipEndpoint);
    }
 
    [Fact]
    public async Task Match_ContentEncoding_AcceptQualityWins_WhenBothAcceptAndEndpointQualityDiffer()
    {
        // Arrange — Accept quality takes precedence over endpoint quality (the tie-breaker).
        // Both endpoints have the same endpoint quality (equal server preference).
        // The client's Accept-Encoding header assigns different qualities to each encoding.
        // Even though endpoint quality would be used as a tie-breaker when Accept qualities are equal,
        // here the Accept quality difference dictates the selection.
        var gzipEndpoint = CreateEndpoint("/hello", contentEncoding: "gzip", quality: 0.9);
        var brEndpoint = CreateEndpoint("/hello", contentEncoding: "br", quality: 0.9);
 
        var matcher = CreateMatcher(gzipEndpoint, brEndpoint);
        var httpContext = CreateContext("/hello", "gzip;q=0.3, br;q=0.9");
 
        // Act
        await matcher.MatchAsync(httpContext);
 
        // Assert — br has higher Accept quality (0.9 > 0.3), so br is selected.
        MatcherAssert.AssertMatch(httpContext, brEndpoint);
    }
 
    [Fact]
    public async Task Match_LiteralEndpointPreserved_WhenCatchAllHasEncodingMetadata()
    {
        // Arrange - This is the core scenario for the priority fix:
        // A literal endpoint without encoding metadata must not be invalidated
        // by a catch-all with encoding metadata.
        var literalEndpoint = CreateEndpoint("/hello");
        var catchAllGzip = CreateEndpoint("/{**path}", contentEncoding: "gzip");
 
        var matcher = CreateMatcher(literalEndpoint, catchAllGzip);
        var httpContext = CreateContext("/hello", "gzip, br");
 
        // Act
        await matcher.MatchAsync(httpContext);
 
        // Assert - literal endpoint wins by route priority despite not matching gzip
        MatcherAssert.AssertMatch(httpContext, literalEndpoint);
    }
 
    [Fact]
    public async Task Match_LiteralEndpointPreserved_WhenCatchAllHasMultipleEncodingVariants()
    {
        // Arrange - Simulates MapStaticAssets: catch-all has identity, gzip, and br variants.
        // The literal endpoint must survive.
        var literalEndpoint = CreateEndpoint("/hello");
        var catchAllIdentity = CreateEndpoint("/{**path}");
        var catchAllGzip = CreateEndpoint("/{**path}", contentEncoding: "gzip");
        var catchAllBr = CreateEndpoint("/{**path}", contentEncoding: "br");
 
        var matcher = CreateMatcher(literalEndpoint, catchAllIdentity, catchAllGzip, catchAllBr);
        var httpContext = CreateContext("/hello", "gzip, br");
 
        // Act
        await matcher.MatchAsync(httpContext);
 
        // Assert - literal wins by route priority
        MatcherAssert.AssertMatch(httpContext, literalEndpoint);
    }
 
    [Fact]
    public async Task Match_ParameterizedEndpointPreserved_WhenCatchAllHasEncodingMetadata()
    {
        // Arrange
        var paramEndpoint = CreateEndpoint("/hello/{name}");
        var catchAllGzip = CreateEndpoint("/{**path}", contentEncoding: "gzip");
 
        var matcher = CreateMatcher(paramEndpoint, catchAllGzip);
        var httpContext = CreateContext("/hello/world", "gzip");
 
        // Act
        await matcher.MatchAsync(httpContext);
 
        // Assert - parameterized endpoint wins by route priority
        MatcherAssert.AssertMatch(httpContext, paramEndpoint, new { name = "world" });
    }
 
    [Fact]
    public async Task Match_MixedPriorities_LiteralWinsOverParameterizedAndCatchAll()
    {
        // Arrange - Multiple priority tiers:
        //   /literal/path           (exact, no metadata)
        //   /literal/{param}        (parameterized, no metadata)
        //   {**catchall} gzip       (catch-all with encoding metadata)
        var literalEndpoint = CreateEndpoint("/literal/path");
        var paramEndpoint = CreateEndpoint("/literal/{param}");
        var catchAllGzip = CreateEndpoint("/{**path}", contentEncoding: "gzip");
 
        var matcher = CreateMatcher(literalEndpoint, paramEndpoint, catchAllGzip);
        var httpContext = CreateContext("/literal/path", "gzip, br");
 
        // Act
        await matcher.MatchAsync(httpContext);
 
        // Assert - literal wins (highest priority)
        MatcherAssert.AssertMatch(httpContext, literalEndpoint);
    }
 
    [Fact]
    public async Task Match_EncodingNegotiationWorksWithinSamePriority()
    {
        // Arrange - Two variants of the same resource at the same route priority.
        // Encoding negotiation should still pick the best encoding.
        var gzipEndpoint = CreateEndpoint("/hello", contentEncoding: "gzip", quality: 0.8);
        var identityEndpoint = CreateEndpoint("/hello");
 
        var matcher = CreateMatcher(gzipEndpoint, identityEndpoint);
        var httpContext = CreateContext("/hello", "gzip");
 
        // Act
        await matcher.MatchAsync(httpContext);
 
        // Assert - gzip wins (encoding match within same priority)
        MatcherAssert.AssertMatch(httpContext, gzipEndpoint);
    }
 
    [Fact]
    public async Task Match_StaticFileEncodingVariants_CatchAllDoesNotInterfere()
    {
        // Arrange - Simulates a static file with encoding variants plus a catch-all:
        //   /style.css identity    (no metadata)
        //   /style.css gzip        (gzip metadata)
        //   {**path}   gzip        (catch-all with gzip)
        var styleIdentity = CreateEndpoint("/style.css");
        var styleGzip = CreateEndpoint("/style.css", contentEncoding: "gzip", quality: 0.8);
        var catchAllGzip = CreateEndpoint("/{**path}", contentEncoding: "gzip", quality: 0.8);
 
        var matcher = CreateMatcher(styleIdentity, styleGzip, catchAllGzip);
        var httpContext = CreateContext("/style.css", "gzip");
 
        // Act
        await matcher.MatchAsync(httpContext);
 
        // Assert - style.css gzip wins (same priority as identity, better encoding match)
        MatcherAssert.AssertMatch(httpContext, styleGzip);
    }
 
    [Fact]
    public async Task Match_CatchAllSelected_WhenNoHigherPriorityMatch()
    {
        // Arrange - Request to a path that only matches the catch-all.
        // Encoding negotiation should work normally.
        var literalEndpoint = CreateEndpoint("/hello");
        var catchAllGzip = CreateEndpoint("/{**path}", contentEncoding: "gzip");
        var catchAllIdentity = CreateEndpoint("/{**path}");
 
        var matcher = CreateMatcher(literalEndpoint, catchAllGzip, catchAllIdentity);
        var httpContext = CreateContext("/other", "gzip");
 
        // Act
        await matcher.MatchAsync(httpContext);
 
        // Assert - catch-all gzip wins (only catch-all matches this path)
        MatcherAssert.AssertMatch(httpContext, catchAllGzip, new { path = "other" });
    }
 
    [Fact]
    public async Task Match_Returns406_WhenNoEndpointMatchesEncoding_AndNoIdentityFallback()
    {
        // Arrange - Only encoded variants exist, none match the Accept-Encoding.
        var gzipEndpoint = CreateEndpoint("/hello", contentEncoding: "gzip");
        var brEndpoint = CreateEndpoint("/hello", contentEncoding: "br");
 
        var matcher = CreateMatcher(gzipEndpoint, brEndpoint);
        var httpContext = CreateContext("/hello", "zstd");
 
        // Act
        await matcher.MatchAsync(httpContext);
 
        // Assert - 406 endpoint is selected (no match, no identity fallback)
        Assert.NotNull(httpContext.GetEndpoint());
        Assert.Equal(NegotiationMatcherPolicy<ContentEncodingMetadata>.Http406EndpointDisplayName, httpContext.GetEndpoint().DisplayName);
    }
 
    private static Matcher CreateMatcher(params RouteEndpoint[] endpoints)
    {
        var services = new ServiceCollection()
            .AddOptions()
            .AddLogging()
            .AddRouting()
            .BuildServiceProvider();
 
        var builder = services.GetRequiredService<DfaMatcherBuilder>();
        for (var i = 0; i < endpoints.Length; i++)
        {
            builder.AddEndpoint(endpoints[i]);
        }
 
        return builder.Build();
    }
 
    internal static HttpContext CreateContext(string path, string acceptEncoding)
    {
        var httpContext = new DefaultHttpContext();
        httpContext.Request.Path = path;
        httpContext.Request.Headers["Accept-Encoding"] = acceptEncoding;
 
        return httpContext;
    }
 
    internal RouteEndpoint CreateEndpoint(
        string template,
        object defaults = null,
        object constraints = null,
        int order = 0,
        string contentEncoding = null,
        double quality = 1.0)
    {
        var metadata = new List<object>();
        if (contentEncoding != null)
        {
            metadata.Add(new ContentEncodingMetadata(contentEncoding, quality));
        }
 
        if (HasDynamicMetadata)
        {
            metadata.Add(new DynamicEndpointMetadata());
        }
 
        var displayName = "endpoint: " + template + " " + (contentEncoding ?? "identity") + " q=" + quality;
        return new RouteEndpoint(
            TestConstants.EmptyRequestDelegate,
            RoutePatternFactory.Parse(template, defaults, constraints),
            order,
            new EndpointMetadataCollection(metadata),
            displayName);
    }
 
    private class DynamicEndpointMetadata : IDynamicEndpointMetadata
    {
        public bool IsDynamic => true;
    }
}