File: StaticAssetsIntegrationTests.cs
Web Access
Project: src\src\StaticAssets\test\Microsoft.AspNetCore.StaticAssets.Tests.csproj (Microsoft.AspNetCore.StaticAssets.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Globalization;
using System.IO.Compression;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Primitives;
 
namespace Microsoft.AspNetCore.StaticAssets.Tests;
 
public class StaticAssetsIntegrationTests
{
    [Fact]
    public async Task CanServeAssetsFromManifestAsync()
    {
        // Arrange
        var appName = nameof(CanServeAssetsFromManifestAsync);
        var (contentRoot, webRoot) = ConfigureAppPaths(appName);
 
        CreateTestManifest(
            appName,
            webRoot,
            [
                new TestResource("sample.txt", "Hello, World!", false),
            ]);
 
        var builder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions
        {
            ApplicationName = appName,
            ContentRootPath = contentRoot,
            EnvironmentName = "Development",
            WebRootPath = webRoot
        });
        builder.WebHost.ConfigureServices(services =>
        {
            services.AddRouting();
        });
        builder.WebHost.UseTestServer();
 
        var app = builder.Build();
        app.UseRouting();
        app.UseEndpoints(endpoints =>
        {
            endpoints.MapStaticAssets();
        });
 
        await app.StartAsync();
 
        var client = app.GetTestClient();
 
        // Act
        var response = await client.GetAsync("/sample.txt");
        Assert.NotNull(response);
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        Assert.Equal($"\"{GetEtag("Hello, World!")}\"", response.Headers.ETag.Tag);
        Assert.Equal(13, response.Content.Headers.ContentLength);
        Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString());
        Assert.Equal("Hello, World!", await response.Content.ReadAsStringAsync());
 
        Directory.Delete(webRoot, true);
    }
 
    [Fact]
    public async Task CachingHeadersAreDisabled_InDevelopment()
    {
        // Arrange
        var appName = nameof(CachingHeadersAreDisabled_InDevelopment);
        var (contentRoot, webRoot) = ConfigureAppPaths(appName);
 
        CreateTestManifest(
            appName,
            webRoot,
            [
                new TestResource("sample.txt", "Hello, World!", false, [new("Cache-Control", "immutable")]),
            ]);
 
        var builder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions
        {
            ApplicationName = appName,
            ContentRootPath = contentRoot,
            EnvironmentName = "Development",
            WebRootPath = webRoot
        });
        builder.WebHost.ConfigureServices(services =>
        {
            services.AddRouting();
        });
        builder.WebHost.UseTestServer();
 
        var app = builder.Build();
        app.UseRouting();
        app.UseEndpoints(endpoints =>
        {
            endpoints.MapStaticAssets();
        });
 
        await app.StartAsync();
 
        var client = app.GetTestClient();
 
        // Act
        var response = await client.GetAsync("/sample.txt");
        Assert.NotNull(response);
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        Assert.Equal($"\"{GetEtag("Hello, World!")}\"", response.Headers.ETag.Tag);
        Assert.Equal(13, response.Content.Headers.ContentLength);
        Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString());
        Assert.Equal("Hello, World!", await response.Content.ReadAsStringAsync());
        Assert.True(response.Headers.CacheControl.NoCache);
 
        Directory.Delete(webRoot, true);
    }
 
    [Fact]
    public async Task CanEnable_CachingHeadersAreDisabled_InDevelopment()
    {
        // Arrange
        var appName = nameof(CanEnable_CachingHeadersAreDisabled_InDevelopment);
        var (contentRoot, webRoot) = ConfigureAppPaths(appName);
 
        CreateTestManifest(
            appName,
            webRoot,
            [
                new TestResource("sample.txt", "Hello, World!", false, [new("Cache-Control", "immutable")]),
            ]);
 
        var builder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions
        {
            ApplicationName = appName,
            ContentRootPath = contentRoot,
            EnvironmentName = "Development",
            WebRootPath = webRoot
        });
        builder.WebHost.ConfigureServices(services =>
        {
            services.AddRouting();
        });
        builder.Configuration["EnableStaticAssetsDevelopmentCaching"] = "true";
        builder.WebHost.UseTestServer();
 
        var app = builder.Build();
        app.UseRouting();
        app.UseEndpoints(endpoints =>
        {
            endpoints.MapStaticAssets();
        });
 
        await app.StartAsync();
 
        var client = app.GetTestClient();
 
        // Act
        var response = await client.GetAsync("/sample.txt");
        Assert.NotNull(response);
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        Assert.Equal($"\"{GetEtag("Hello, World!")}\"", response.Headers.ETag.Tag);
        Assert.Equal(13, response.Content.Headers.ContentLength);
        Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString());
        Assert.Equal("Hello, World!", await response.Content.ReadAsStringAsync());
        Assert.Equal("immutable", response.Headers.CacheControl.ToString());
 
        Directory.Delete(webRoot, true);
    }
 
    [Fact]
    public async Task Integrity_IsDisabled_InDevelopment()
    {
        // Arrange
        var appName = nameof(Integrity_IsDisabled_InDevelopment);
        var (contentRoot, webRoot) = ConfigureAppPaths(appName);
 
        CreateTestManifest(
            appName,
            webRoot,
            [
                new TestResource("sample.txt", "Hello, World!", false, [new("Cache-Control", "immutable")]),
            ]);
 
        var builder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions
        {
            ApplicationName = appName,
            ContentRootPath = contentRoot,
            EnvironmentName = "Development",
            WebRootPath = webRoot
        });
        builder.WebHost.ConfigureServices(services =>
        {
            services.AddRouting();
        });
        builder.WebHost.UseTestServer();
 
        var app = builder.Build();
        app.UseRouting();
        app.UseEndpoints(endpoints =>
        {
            var builder = endpoints.MapStaticAssets();
            var descriptors = builder.Descriptors;
 
            endpoints.MapGet("/has-integrity", context =>
            {
                var descriptor = descriptors[0];
                var integrity = descriptors[0].Properties.FirstOrDefault(p => p.Name == "integrity");
                if (integrity != null)
                {
                    context.Response.StatusCode = 400;
                }
                return Task.CompletedTask;
            });
        });
 
        await app.StartAsync();
 
        var client = app.GetTestClient();
 
        // Act
        var response = await client.GetAsync("/has-integrity");
        Assert.NotNull(response);
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        Directory.Delete(webRoot, true);
    }
 
    [Fact]
    public async Task CanEnableIntegrity_InDevelopment()
    {
        // Arrange
        var appName = nameof(Integrity_IsDisabled_InDevelopment);
        var (contentRoot, webRoot) = ConfigureAppPaths(appName);
 
        CreateTestManifest(
            appName,
            webRoot,
            [
                new TestResource("sample.txt", "Hello, World!", false, [new("Cache-Control", "immutable")]),
            ]);
 
        var builder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions
        {
            ApplicationName = appName,
            ContentRootPath = contentRoot,
            EnvironmentName = "Development",
            WebRootPath = webRoot
        });
        builder.WebHost.ConfigureServices(services =>
        {
            services.AddRouting();
        });
        builder.WebHost.UseTestServer();
        builder.Configuration["EnableStaticAssetsDevelopmentIntegrity"] = "true";
        var app = builder.Build();
        app.UseRouting();
        app.UseEndpoints(endpoints =>
        {
            var builder = endpoints.MapStaticAssets();
            var descriptors = builder.Descriptors;
 
            endpoints.MapGet("/has-integrity", context =>
            {
                var descriptor = descriptors[0];
                var integrity = descriptors[0].Properties.FirstOrDefault(p => p.Name == "integrity");
                if (integrity == null)
                {
                    context.Response.StatusCode = 400;
                }
                return Task.CompletedTask;
            });
        });
 
        await app.StartAsync();
 
        var client = app.GetTestClient();
 
        // Act
        var response = await client.GetAsync("/has-integrity");
        Assert.NotNull(response);
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        Directory.Delete(webRoot, true);
    }
 
    [Fact]
    public async Task CanServeNewFilesAddedAfterBuildDuringDevelopment()
    {
        // Arrange
        var appName = nameof(CanServeNewFilesAddedAfterBuildDuringDevelopment);
        var (contentRoot, webRoot) = ConfigureAppPaths(appName);
 
        CreateTestManifest(
            appName,
            webRoot,
            []);
 
        var builder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions
        {
            ApplicationName = appName,
            ContentRootPath = contentRoot,
            EnvironmentName = "Development",
            WebRootPath = webRoot
        });
 
        builder.WebHost.UseSetting(StaticAssetDevelopmentRuntimeHandler.ReloadStaticAssetsAtRuntimeKey, "true");
        builder.WebHost.ConfigureServices(services =>
        {
            services.AddRouting();
        });
        builder.WebHost.UseTestServer();
 
        var app = builder.Build();
        app.UseRouting();
        app.UseEndpoints(endpoints =>
        {
            endpoints.MapStaticAssets();
        });
 
        await app.StartAsync();
 
        var filePath = Path.Combine(webRoot, "sample.txt");
        var lastModified = DateTimeOffset.UtcNow;
        File.WriteAllText(filePath, "Hello, World!");
 
        var client = app.GetTestClient();
 
        // Act
        var response = await client.GetAsync("/sample.txt");
        Assert.NotNull(response);
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        Assert.Equal($"\"3/1gIbsr1bCvZ2KQgJ7DpTGR3YHH9wpLKGiKNiGCmG8=\"", response.Headers.ETag.Tag);
        Assert.Equal(13, response.Content.Headers.ContentLength);
        Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString());
        Assert.Equal("Hello, World!", await response.Content.ReadAsStringAsync());
 
        Directory.Delete(webRoot, true);
    }
 
    [Fact]
    public async Task CanModifyAssetsOnTheFlyInDevelopment()
    {
        // Arrange
        var appName = nameof(CanModifyAssetsOnTheFlyInDevelopment);
        var (contentRoot, webRoot) = ConfigureAppPaths(appName);
 
        CreateTestManifest(
            appName,
            webRoot,
            [
                new TestResource("sample.txt", "Hello, World!", false),
            ]);
 
        var builder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions
        {
            ApplicationName = appName,
            ContentRootPath = contentRoot,
            EnvironmentName = "Development",
            WebRootPath = webRoot
        });
        builder.WebHost.UseSetting(StaticAssetDevelopmentRuntimeHandler.ReloadStaticAssetsAtRuntimeKey, "true");
        builder.WebHost.ConfigureServices(services =>
        {
            services.AddRouting();
        });
        builder.WebHost.UseTestServer();
 
        var app = builder.Build();
        app.UseRouting();
        app.UseEndpoints(endpoints =>
        {
            endpoints.MapStaticAssets();
        });
 
        await app.StartAsync();
 
        var client = app.GetTestClient();
 
        File.WriteAllText(Path.Combine(webRoot, "sample.txt"), "Hello, World! Modified");
 
        // Act
        var response = await client.GetAsync("/sample.txt");
        Assert.NotNull(response);
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        Assert.Equal($"\"+fvSyRQcr4/t/rcA0u1KfZ8c3CpXxBDxsxDhnAftNqg=\"", response.Headers.ETag.Tag);
        Assert.Equal(22, response.Content.Headers.ContentLength);
        Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString());
        Assert.Equal("Hello, World! Modified", await response.Content.ReadAsStringAsync());
 
        Directory.Delete(webRoot, true);
    }
 
    [Fact]
    public async Task CanModifyAssetsWithCompressedVersionsOnTheFlyInDevelopment()
    {
        // Arrange
        var appName = nameof(CanModifyAssetsWithCompressedVersionsOnTheFlyInDevelopment);
        var (contentRoot, webRoot) = ConfigureAppPaths(appName);
 
        CreateTestManifest(
            appName,
            webRoot,
            [
                new TestResource("sample.txt", "Hello, World!", true),
            ]);
 
        var builder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions
        {
            ApplicationName = appName,
            ContentRootPath = contentRoot,
            EnvironmentName = "Development",
            WebRootPath = webRoot
        });
        builder.WebHost.UseSetting(StaticAssetDevelopmentRuntimeHandler.ReloadStaticAssetsAtRuntimeKey, "true");
        builder.WebHost.ConfigureServices(services =>
        {
            services.AddRouting();
        });
        builder.WebHost.UseTestServer();
 
        var app = builder.Build();
        app.UseRouting();
        app.UseEndpoints(endpoints =>
        {
            endpoints.MapStaticAssets();
        });
 
        await app.StartAsync();
 
        var client = app.GetTestClient();
 
        File.WriteAllText(Path.Combine(webRoot, "sample.txt"), "Hello, World! Modified");
 
        // Act
        var message = new HttpRequestMessage(HttpMethod.Get, "/sample.txt");
        message.Headers.AcceptEncoding.Add(new StringWithQualityHeaderValue("gzip"));
        var response = await client.SendAsync(message);
 
        Assert.NotNull(response);
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        Assert.Equal(GetGzipEtag(Path.Combine(webRoot, "sample.txt")), response.Headers.ETag.Tag);
        Assert.Equal(55, response.Content.Headers.ContentLength);
        Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString());
        var gzipContent = await response.Content.ReadAsStreamAsync();
        using var gzipStream = new GZipStream(gzipContent, CompressionMode.Decompress);
        using var reader = new StreamReader(gzipStream);
        var content = reader.ReadToEnd();
        Assert.Equal("Hello, World! Modified", content);
 
        Directory.Delete(webRoot, true);
    }
 
    private string GetGzipEtag(string filePath)
    {
        using var stream = new MemoryStream();
        using (var fileStream = File.OpenRead(filePath))
        {
            using var gzipStream = new GZipStream(stream, CompressionLevel.NoCompression, leaveOpen: true);
            fileStream.CopyTo(gzipStream);
            gzipStream.Flush();
        }
        stream.Flush();
        stream.Seek(0, SeekOrigin.Begin);
 
        return $"\"{Convert.ToBase64String(SHA256.HashData(stream))}\"";
    }
 
    private static (string contentRoot, string webRoot) ConfigureAppPaths(string appName)
    {
        var contentRoot = Path.Combine(AppContext.BaseDirectory, appName);
        var webRoot = Path.Combine(contentRoot, "wwwroot");
 
        return (contentRoot, webRoot);
    }
 
    private static void CreateTestManifest(string appName, string webRoot, params Span<TestResource> resources)
    {
        Directory.CreateDirectory(webRoot);
        var manifestPath = Path.Combine(AppContext.BaseDirectory, $"{appName}.staticwebassets.endpoints.json");
        var manifest = new StaticAssetsManifest()
        {
            Version = 1
        };
 
        for (var i = 0; i < resources.Length; i++)
        {
            var resource = resources[i];
            var filePath = Path.Combine(webRoot, resource.Path);
            var lastModified = DateTimeOffset.UtcNow;
            File.WriteAllText(filePath, resource.Content);
            var hash = GetEtag(resource.Content);
            manifest.ManifestType = "Build";
            manifest.Endpoints.Add(new StaticAssetDescriptor
            {
                Route = resource.Path,
                AssetPath = resource.Path,
                Selectors = [],
                Properties = [new("integrity", $"sha256-{hash}")],
                ResponseHeaders = [
                    new ("Accept-Ranges", "bytes"),
                    new("Content-Length", resource.Content.Length.ToString(CultureInfo.InvariantCulture)),
                    new("Content-Type", GetContentType(filePath)),
                    new ("ETag", $"\"{hash}\""),
                    new("Last-Modified", lastModified.ToString("ddd, dd MMM yyyy HH:mm:ss 'GMT'", CultureInfo.InvariantCulture)),
                    ..(resource.AdditionalHeaders ?? [])
                ]
            });
 
            if (resource.IncludeCompressedVersion)
            {
                var compressedFilePath = Path.Combine(webRoot, resource.Path + ".gz");
                var length = CreateCompressedFile(compressedFilePath, resource);
 
                manifest.Endpoints.Add(new StaticAssetDescriptor
                {
                    Route = resource.Path,
                    AssetPath = $"{resource.Path}.gz",
                    Selectors = [new StaticAssetSelector("Content-Encoding", "gzip", "1.0")],
                    Properties = [],
                    ResponseHeaders = [
                        new ("Accept-Ranges", "bytes"),
                        new ("Content-Type", GetContentType(filePath)),
 
                        new ("Content-Length", length.ToString(CultureInfo.InvariantCulture)),
                        new ("ETag", $"W/\"{GetEtag(resource.Content)}\""),
                        new ("ETag", $"\"{GetEtagForFile(compressedFilePath)}\""),
                        new ("Last-Modified", lastModified.ToString("ddd, dd MMM yyyy HH:mm:ss 'GMT'", CultureInfo.InvariantCulture)),
 
                        new ("Content-Encoding", "gzip"),
                        new ("Vary", "Accept-Encoding"),
                    ]
                });
            }
        }
        using var stream = File.Create(manifestPath);
        using var writer = new Utf8JsonWriter(stream);
        JsonSerializer.Serialize(writer, manifest);
    }
 
    private static long CreateCompressedFile(string filePath, TestResource resource)
    {
        using var fileStream = File.Create(filePath);
        using var gzipStream = new GZipStream(fileStream, CompressionLevel.Fastest);
        using var compressedWriter = new StreamWriter(gzipStream);
        compressedWriter.Write(resource.Content);
        compressedWriter.Flush();
        return fileStream.Length;
    }
 
    private static string GetEtagForFile(string compressedFilePath)
    {
        using var stream = File.OpenRead(compressedFilePath);
        return Convert.ToBase64String(SHA256.HashData(stream));
    }
 
    private static string GetEtag(string content)
    {
        var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
        return Convert.ToBase64String(hash);
    }
 
    private static async Task<HttpClient> CreateClient()
    {
        // Arrange
        // These aren't used as we are replacing the file provider with a test one
        var (contentRoot, webRoot) = (AppContext.BaseDirectory, AppContext.BaseDirectory);
 
        var manifest = new StaticAssetsManifest()
        {
            Version = 1
        };
        manifest.Endpoints.Add(new StaticAssetDescriptor
        {
            Route = "sample.txt",
            AssetPath = "sample.txt",
            Selectors = [],
            Properties = [],
            ResponseHeaders = [
                new ("Accept-Ranges", "bytes"),
                new("Content-Length", "Hello, World!".Length.ToString(CultureInfo.InvariantCulture)),
                new("Content-Type", GetContentType("sample.txt")),
                new ("ETag", $"\"{GetEtag("Hello, World!")}\""),
                new("Last-Modified", new DateTimeOffset(2023,03,03,0,0,0,TimeSpan.Zero).ToString("ddd, dd MMM yyyy HH:mm:ss 'GMT'", CultureInfo.InvariantCulture))
            ]
        });
 
        var builder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions
        {
            ApplicationName = "InMemory",
            ContentRootPath = contentRoot,
            EnvironmentName = "Development",
            WebRootPath = webRoot
        });
        builder.Environment.WebRootFileProvider = new TestFileProvider(new TestResource[]
        {
            new("sample.txt", "Hello, World!", false),
        });
        builder.WebHost.ConfigureServices(services =>
        {
            services.AddRouting();
        });
        builder.WebHost.UseTestServer();
 
        var app = builder.Build();
        app.UseRouting();
        app.UseEndpoints(endpoints =>
        {
            endpoints.MapStaticAssets(manifest);
        });
 
        await app.StartAsync();
 
        return app.GetTestClient();
    }
 
    [Fact]
    public async Task ServerShouldReturnETag()
    {
        var client = await CreateClient();
        var response = await client.GetAsync("http://localhost/sample.txt");
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        Assert.NotNull(response.Headers.ETag);
        Assert.NotNull(response.Headers.ETag.Tag);
    }
 
    [Fact]
    public async Task SameETagShouldBeReturnedAgain()
    {
        var client = await CreateClient();
        var response1 = await client.GetAsync("http://localhost/sample.txt");
        var response2 = await client.GetAsync("http://localhost/sample.txt");
        Assert.Equal(response2.Headers.ETag, response1.Headers.ETag);
    }
 
    //// 14.24 If-Match
    //// If none of the entity tags match, or if "*" is given and no current
    //// entity exists, the server MUST NOT perform the requested method, and
    //// MUST return a 412 (Precondition Failed) response. This behavior is
    //// most useful when the client wants to prevent an updating method, such
    //// as PUT, from modifying a resource that has changed since the client
    //// last retrieved it.
 
    [Theory]
    [MemberData(nameof(SupportedMethods))]
    public async Task IfMatchShouldReturn412WhenNotListed(HttpMethod method)
    {
        var client = await CreateClient();
        var req = new HttpRequestMessage(method, "http://localhost/sample.txt");
        req.Headers.Add("If-Match", "\"fake\"");
        var resp = await client.SendAsync(req);
        Assert.Equal(HttpStatusCode.PreconditionFailed, resp.StatusCode);
    }
 
    [Theory]
    [MemberData(nameof(SupportedMethods))]
    public async Task IfMatchShouldBeServedWhenListed(HttpMethod method)
    {
        var client = await CreateClient();
        var original = await client.GetAsync("http://localhost/sample.txt");
 
        var req = new HttpRequestMessage(method, "http://localhost/sample.txt");
        req.Headers.Add("If-Match", original.Headers.ETag.ToString());
        var resp = await client.SendAsync(req);
        Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
    }
 
    [Theory]
    [MemberData(nameof(SupportedMethods))]
    public async Task IfMatchShouldBeServedForAsterisk(HttpMethod method)
    {
        var client = await CreateClient();
        var req = new HttpRequestMessage(method, "http://localhost/sample.txt");
        req.Headers.Add("If-Match", "*");
        var resp = await client.SendAsync(req);
        Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
    }
 
    [Theory]
    [MemberData(nameof(UnsupportedMethods))]
    public async Task IfMatchShouldBeIgnoredForUnsupportedMethods(HttpMethod method)
    {
        var client = await CreateClient();
        var req = new HttpRequestMessage(method, "http://localhost/sample.txt");
        req.Headers.Add("If-Match", "*");
        var resp = await client.SendAsync(req);
        Assert.Equal(HttpStatusCode.MethodNotAllowed, resp.StatusCode);
    }
 
    // 14.26 If-None-Match
    // If any of the entity tags match the entity tag of the entity that
    // would have been returned in the response to a similar GET request
    // (without the If-None-Match header) on that resource, or if "*" is
    // given and any current entity exists for that resource, then the
    // server MUST NOT perform the requested method, unless required to do
    // so because the resource's modification date fails to match that
    // supplied in an If-Modified-Since header field in the request.
    // Instead, if the request method was GET or HEAD, the server SHOULD
    // respond with a 304 (Not Modified) response, including the cache-
    // related header fields (particularly ETag) of one of the entities that
    // matched. For all other request methods, the server MUST respond with
    // a status of 412 (Precondition Failed).
 
    [Theory]
    [MemberData(nameof(SupportedMethods))]
    public async Task IfNoneMatchShouldReturn304ForMatching(HttpMethod method)
    {
        var client = await CreateClient();
        var resp1 = await client.GetAsync("http://localhost/sample.txt");
 
        var req2 = new HttpRequestMessage(method, "http://localhost/sample.txt");
        req2.Headers.Add("If-None-Match", resp1.Headers.ETag.ToString());
        var resp2 = await client.SendAsync(req2);
        Assert.Equal(HttpStatusCode.NotModified, resp2.StatusCode);
    }
 
    [Theory]
    [MemberData(nameof(SupportedMethods))]
    public async Task IfNoneMatchAllShouldReturn304ForMatching(HttpMethod method)
    {
        var client = await CreateClient();
        var resp1 = await client.GetAsync("http://localhost/sample.txt");
 
        var req2 = new HttpRequestMessage(method, "http://localhost/sample.txt");
        req2.Headers.Add("If-None-Match", "*");
        var resp2 = await client.SendAsync(req2);
        Assert.Equal(HttpStatusCode.NotModified, resp2.StatusCode);
    }
 
    [Theory]
    [MemberData(nameof(UnsupportedMethods))]
    public async Task IfNoneMatchShouldBeIgnoredForNonTwoHundredAnd304Responses(HttpMethod method)
    {
        var client = await CreateClient();
        var resp1 = await client.GetAsync("http://localhost/sample.txt");
 
        var req2 = new HttpRequestMessage(method, "http://localhost/sample.txt");
        req2.Headers.Add("If-None-Match", resp1.Headers.ETag.ToString());
        var resp2 = await client.SendAsync(req2);
        Assert.Equal(HttpStatusCode.MethodNotAllowed, resp2.StatusCode);
    }
 
    // 14.26 If-None-Match
    // If none of the entity tags match, then the server MAY perform the
    // requested method as if the If-None-Match header field did not exist,
    // but MUST also ignore any If-Modified-Since header field(s) in the
    // request. That is, if no entity tags match, then the server MUST NOT
    // return a 304 (Not Modified) response.
 
    // A server MUST use the strong comparison function (see section 13.3.3)
    // to compare the entity tags in If-Match.
 
    [Theory]
    [MemberData(nameof(SupportedMethods))]
    public async Task ServerShouldReturnLastModified(HttpMethod method)
    {
        var client = await CreateClient();
 
        var response = await client.SendAsync(
            new HttpRequestMessage(method, "http://localhost/sample.txt"));
 
        Assert.NotNull(response.Content.Headers.LastModified);
        // Verify that DateTimeOffset is UTC
        Assert.Equal(response.Content.Headers.LastModified.Value.Offset, TimeSpan.Zero);
    }
 
    // 13.3.4
    // An HTTP/1.1 origin server, upon receiving a conditional request that
    // includes both a Last-Modified date (e.g., in an If-Modified-Since or
    // If-Unmodified-Since header field) and one or more entity tags (e.g.,
    // in an If-Match, If-None-Match, or If-Range header field) as cache
    // validators, MUST NOT return a response status of 304 (Not Modified)
    // unless doing so is consistent with all of the conditional header
    // fields in the request.
 
    [Theory]
    [MemberData(nameof(SupportedMethods))]
    public async Task MatchingBothConditionsReturnsNotModified(HttpMethod method)
    {
        var client = await CreateClient();
        var req1 = new HttpRequestMessage(method, "http://localhost/sample.txt");
        var resp1 = await client.SendAsync(req1);
 
        var req2 = new HttpRequestMessage(method, "http://localhost/sample.txt");
        req2.Headers.Add("If-None-Match", resp1.Headers.ETag.ToString());
        req2.Headers.IfModifiedSince = resp1.Content.Headers.LastModified;
        var resp2 = await client.SendAsync(req2);
 
        Assert.Equal(HttpStatusCode.NotModified, resp2.StatusCode);
    }
 
    [Theory]
    [MemberData(nameof(SupportedMethods))]
    public async Task MatchingAtLeastOneETagReturnsNotModified(HttpMethod method)
    {
        var client = await CreateClient();
        var req1 = new HttpRequestMessage(method, "http://localhost/sample.txt");
        var resp1 = await client.SendAsync(req1);
        var etag = resp1.Headers.ETag.ToString();
 
        var req2 = new HttpRequestMessage(method, "http://localhost/sample.txt");
        req2.Headers.Add("If-Match", etag + ", " + etag);
 
        var resp2 = await client.SendAsync(req2);
 
        Assert.Equal(HttpStatusCode.OK, resp2.StatusCode);
 
        var req3 = new HttpRequestMessage(method, "http://localhost/sample.txt");
        req3.Headers.Add("If-Match", etag + ", \"badetag\"");
        var resp3 = await client.SendAsync(req3);
 
        Assert.Equal(HttpStatusCode.OK, resp3.StatusCode);
    }
 
    [Theory]
    [MemberData(nameof(SupportedMethods))]
    public async Task MissingEitherOrBothConditionsReturnsNormally(HttpMethod method)
    {
        var client = await CreateClient();
        var req1 = new HttpRequestMessage(method, "http://localhost/sample.txt");
        var resp1 = await client.SendAsync(req1);
 
        var lastModified = resp1.Content.Headers.LastModified.Value;
        var pastDate = lastModified.AddHours(-1);
        var futureDate = lastModified.AddHours(1);
 
        var req2 = new HttpRequestMessage(method, "http://localhost/sample.txt");
        req2.Headers.IfNoneMatch.Add(new EntityTagHeaderValue("\"fake\""));
        req2.Headers.IfModifiedSince = lastModified;
        var resp2 = await client.SendAsync(req2);
 
        var req3 = new HttpRequestMessage(method, "http://localhost/sample.txt");
        req3.Headers.IfNoneMatch.Add(resp1.Headers.ETag);
        req3.Headers.IfModifiedSince = pastDate;
        var resp3 = await client.SendAsync(req3);
 
        var req4 = new HttpRequestMessage(method, "http://localhost/sample.txt");
        req4.Headers.IfNoneMatch.Add(new EntityTagHeaderValue("\"fake\""));
        req4.Headers.IfModifiedSince = futureDate;
        var resp4 = await client.SendAsync(req4);
 
        Assert.Equal(HttpStatusCode.OK, resp2.StatusCode);
        Assert.Equal(HttpStatusCode.OK, resp3.StatusCode);
        Assert.Equal(HttpStatusCode.OK, resp4.StatusCode);
    }
 
    // 14.25 If-Modified-Since
    // The If-Modified-Since request-header field is used with a method to
    // make it conditional: if the requested variant has not been modified
    // since the time specified in this field, an entity will not be
    // returned from the server; instead, a 304 (not modified) response will
    // be returned without any message-body.
 
    // a) If the request would normally result in anything other than a
    //   200 (OK) status, or if the passed If-Modified-Since date is
    //   invalid, the response is exactly the same as for a normal GET.
    //   A date which is later than the server's current time is
    //   invalid.
    [Theory]
    [MemberData(nameof(SupportedMethods))]
    public async Task InvalidIfModifiedSinceDateFormatGivesNormalGet(HttpMethod method)
    {
        var client = await CreateClient();
 
        var req = new HttpRequestMessage(method, "http://localhost/sample.txt");
        req.Headers.TryAddWithoutValidation("If-Modified-Since", "bad-date");
        var res = await client.SendAsync(req);
 
        Assert.Equal(HttpStatusCode.OK, res.StatusCode);
    }
 
    [Theory]
    [MemberData(nameof(SupportedMethods))]
    public async Task FutureIfModifiedSinceDateFormatGivesNormalGet(HttpMethod method)
    {
        var client = await CreateClient();
 
        var req = new HttpRequestMessage(method, "http://localhost/sample.txt");
        req.Headers.IfModifiedSince = DateTimeOffset.Now.AddYears(1);
        var res = await client.SendAsync(req);
 
        Assert.Equal(HttpStatusCode.OK, res.StatusCode);
    }
 
    // b) If the variant has been modified since the If-Modified-Since
    //   date, the response is exactly the same as for a normal GET.
 
    // c) If the variant has not been modified since a valid If-
    //   Modified-Since date, the server SHOULD return a 304 (Not
    //   Modified) response.
 
    [Theory]
    [MemberData(nameof(SupportedMethods))]
    public async Task IfModifiedSinceDateGreaterThanLastModifiedShouldReturn304(HttpMethod method)
    {
        var client = await CreateClient();
 
        var res1 = await client.SendAsync(
            new HttpRequestMessage(method, "http://localhost/sample.txt"));
 
        var req2 = new HttpRequestMessage(method, "http://localhost/sample.txt");
        req2.Headers.IfModifiedSince = DateTimeOffset.Now;
        var res2 = await client.SendAsync(req2);
 
        Assert.Equal(HttpStatusCode.NotModified, res2.StatusCode);
    }
 
    [Theory]
    [MemberData(nameof(SupportedMethods))]
    public async Task SupportsIfModifiedDateFormats(HttpMethod method)
    {
        var client = await CreateClient();
        var res1 = await client.SendAsync(
            new HttpRequestMessage(method, "http://localhost/sample.txt"));
 
        var formats = new[]
        {
                "ddd, dd MMM yyyy HH:mm:ss 'GMT'",
                "dddd, dd-MMM-yy HH:mm:ss 'GMT'",
                "ddd MMM  d HH:mm:ss yyyy"
            };
 
        foreach (var format in formats)
        {
            var req2 = new HttpRequestMessage(method, "sample.txt");
            req2.Headers.TryAddWithoutValidation("If-Modified-Since", DateTimeOffset.UtcNow.ToString(format, CultureInfo.InvariantCulture));
            var res2 = await client.SendAsync(req2);
 
            Assert.Equal(HttpStatusCode.NotModified, res2.StatusCode);
        }
    }
 
    [Theory]
    [MemberData(nameof(SupportedMethods))]
    public async Task IfModifiedSinceDateLessThanLastModifiedShouldReturn200(HttpMethod method)
    {
        var client = await CreateClient();
 
        var res1 = await client.SendAsync(
            new HttpRequestMessage(method, "http://localhost/sample.txt"));
 
        var req2 = new HttpRequestMessage(method, "http://localhost/sample.txt");
        req2.Headers.IfModifiedSince = DateTimeOffset.MinValue;
        var res2 = await client.SendAsync(req2);
 
        Assert.Equal(HttpStatusCode.OK, res2.StatusCode);
    }
 
    [Theory]
    [MemberData(nameof(SupportedMethods))]
    public async Task InvalidIfUnmodifiedSinceDateFormatGivesNormalGet(HttpMethod method)
    {
        var client = await CreateClient();
 
        var req = new HttpRequestMessage(method, "http://localhost/sample.txt");
        req.Headers.TryAddWithoutValidation("If-Unmodified-Since", "bad-date");
        var res = await client.SendAsync(req);
 
        Assert.Equal(HttpStatusCode.OK, res.StatusCode);
    }
 
    [Theory]
    [MemberData(nameof(SupportedMethods))]
    public async Task FutureIfUnmodifiedSinceDateFormatGivesNormalGet(HttpMethod method)
    {
        var client = await CreateClient();
        var req = new HttpRequestMessage(method, "http://localhost/sample.txt");
        req.Headers.IfUnmodifiedSince = DateTimeOffset.Now.AddYears(1);
        var res = await client.SendAsync(req);
 
        Assert.Equal(HttpStatusCode.OK, res.StatusCode);
    }
 
    [Theory]
    [MemberData(nameof(SupportedMethods))]
    public async Task IfUnmodifiedSinceDateLessThanLastModifiedShouldReturn412(HttpMethod method)
    {
        var client = await CreateClient();
 
        var res1 = await client.SendAsync(
            new HttpRequestMessage(method, "http://localhost/sample.txt"));
 
        var req2 = new HttpRequestMessage(method, "http://localhost/sample.txt");
        req2.Headers.IfUnmodifiedSince = DateTimeOffset.MinValue;
        var res2 = await client.SendAsync(req2);
 
        Assert.Equal(HttpStatusCode.PreconditionFailed, res2.StatusCode);
    }
 
    // 14.35.2 Range Retrieval Requests
    // The presence of a Range header in an unconditional GET modifies
    // what is returned if the GET is otherwise successful. In other
    // words, the response carries a status code of 206 (Partial
    // Content) instead of 200 (OK).
    [Fact]
    public async Task RangeGivesMatchingRange()
    {
        var client = await CreateClient();
 
        var req1 = new HttpRequestMessage(HttpMethod.Get, "http://localhost/sample.txt");
        req1.Headers.Range = new RangeHeaderValue(0, 4);
        var res1 = await client.SendAsync(req1);
 
        var req2 = new HttpRequestMessage(HttpMethod.Get, "http://localhost/sample.txt");
        req2.Headers.Range = new RangeHeaderValue(7, 11);
        var res2 = await client.SendAsync(req2);
 
        Assert.Equal(HttpStatusCode.PartialContent, res1.StatusCode);
        Assert.Equal("Hello", await res1.Content.ReadAsStringAsync());
        Assert.Equal(5, res1.Content.Headers.ContentLength);
 
        Assert.Equal(HttpStatusCode.PartialContent, res2.StatusCode);
        Assert.Equal("World", await res2.Content.ReadAsStringAsync());
        Assert.Equal(5, res2.Content.Headers.ContentLength);
    }
 
    public static IEnumerable<object[]> SupportedMethods => new[]
    {
            new [] { HttpMethod.Get },
            new [] { HttpMethod.Head }
        };
 
    public static IEnumerable<object[]> UnsupportedMethods => new[]
    {
            new [] { HttpMethod.Post },
            new [] { HttpMethod.Put },
            new [] { HttpMethod.Options },
            new [] { HttpMethod.Trace },
            new [] { new HttpMethod("VERB") }
        };
 
    private static string GetContentType(string filePath)
    {
        return Path.GetExtension(filePath) switch
        {
            ".txt" => "text/plain",
            ".html" => "text/html",
            ".css" => "text/css",
            ".js" => "application/javascript",
            ".json" => "application/json",
            ".png" => "image/png",
            ".jpg" => "image/jpeg",
            ".jpeg" => "image/jpeg",
            _ => "application/octet-stream"
        };
    }
 
    private record TestResource(string Path, string Content, bool IncludeCompressedVersion, StaticAssetResponseHeader[] AdditionalHeaders = null);
 
    private class TestFileProvider(TestResource[] testResources) : IFileProvider
    {
        public IDirectoryContents GetDirectoryContents(string subpath)
        {
            return NotFoundDirectoryContents.Singleton;
        }
 
        public IFileInfo GetFileInfo(string subpath)
        {
            for (var i = 0; i < testResources.Length; i++)
            {
                if (testResources[i].Path == subpath)
                {
                    return new TestFileInfo(testResources[i]);
                }
            }
 
            return new NotFoundFileInfo(subpath);
        }
 
        public IChangeToken Watch(string filter)
        {
            return NullChangeToken.Singleton;
        }
 
        private class TestFileInfo(TestResource testResource) : IFileInfo
        {
            public bool Exists => true;
 
            public long Length => testResource.Content.Length;
 
            public string PhysicalPath => null;
 
            public string Name => Path.GetFileName(testResource.Path);
 
            public DateTimeOffset LastModified => new(2023, 03, 03, 0, 0, 0, TimeSpan.Zero);
 
            public bool IsDirectory => false;
 
            public Stream CreateReadStream()
            {
                var stream = new MemoryStream();
                var writer = new StreamWriter(stream);
                writer.Write(testResource.Content);
                writer.Flush();
                stream.Position = 0;
                return stream;
            }
        }
    }
}