/
githubmirror
/
aspnetcore
Обзор
Документация
Войти
/
githubmirror
/
aspnetcore
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
src/StaticAssets/test/StaticAssetsIntegrationTests.cs
1 407 строк
51 KB
Javier Calvarro Nelson
[StaticAssets] Read Order from endpoint manifest in StaticAssetEndpointFactory (#65975)
05 апр 2026, 18:58
Не верифицирован
05 апр 2026, 18:58
99e8596
Код
Авторство
О чём код?
// 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.Routing; 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 RangeRequestReturnsCorrectContentLengthForModifiedAssets() { // Arrange var appName = nameof(RangeRequestReturnsCorrectContentLengthForModifiedAssets); 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 - Request first 5 bytes (Range: bytes=0-4) var message = new HttpRequestMessage(HttpMethod.Get, "/sample.txt"); message.Headers.Range = new RangeHeaderValue(0, 4); var response = await client.SendAsync(message); // Assert - Should return 206 Partial Content with correct Content-Length of 5 bytes Assert.NotNull(response); Assert.Equal(HttpStatusCode.PartialContent, response.StatusCode); Assert.Equal(5, response.Content.Headers.ContentLength); Assert.Equal("Hello", await response.Content.ReadAsStringAsync()); // Act - Request bytes 7-11 (Range: bytes=7-11) var message2 = new HttpRequestMessage(HttpMethod.Get, "/sample.txt"); message2.Headers.Range = new RangeHeaderValue(7, 11); var response2 = await client.SendAsync(message2); // Assert - Should return 206 Partial Content with correct Content-Length of 5 bytes Assert.NotNull(response2); Assert.Equal(HttpStatusCode.PartialContent, response2.StatusCode); Assert.Equal(5, response2.Content.Headers.ContentLength); Assert.Equal("World", await response2.Content.ReadAsStringAsync()); Directory.Delete(webRoot, true); } [Fact] public async Task RangeRequestReturns416WhenFileShrinksAndRangeIsUnsatisfiable() { // Arrange - Start with a larger file, then shrink it var appName = nameof(RangeRequestReturns416WhenFileShrinksAndRangeIsUnsatisfiable); var (contentRoot, webRoot) = ConfigureAppPaths(appName); CreateTestManifest( appName, webRoot, [ new TestResource("sample.txt", "Hello, World! This is a longer file for testing.", 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(); // Shrink the file File.WriteAllText(Path.Combine(webRoot, "sample.txt"), "Short"); // Act - Request a range that was valid for the original file but not for the shrunken file var message = new HttpRequestMessage(HttpMethod.Get, "/sample.txt"); message.Headers.Range = new RangeHeaderValue(10, 20); // bytes 10-20, but file is now only 5 bytes var response = await client.SendAsync(message); // Assert - Should return 416 Range Not Satisfiable Assert.NotNull(response); Assert.Equal(HttpStatusCode.RequestedRangeNotSatisfiable, response.StatusCode); var contentRange = await response.Content.ReadAsStringAsync(); Assert.True(response.Content.Headers.TryGetValues("Content-Range", out var values)); Assert.Contains("bytes */5", values); 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 = [new("original-resource", $"\"{GetEtag(resource.Content)}\"")], ResponseHeaders = [ new ("Accept-Ranges", "bytes"), new ("Content-Type", GetContentType(filePath)), new ("Content-Length", length.ToString(CultureInfo.InvariantCulture)), 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; } } } [Fact] public async Task EndpointOrder_DefaultsToNegative100_WhenOrderNotSet() { // Arrange var appName = nameof(EndpointOrder_DefaultsToNegative100_WhenOrderNotSet); 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(); // Act var endpoint = app.Services.GetRequiredService<EndpointDataSource>().Endpoints .OfType<RouteEndpoint>() .Single(e => e.RoutePattern.RawText == "sample.txt"); // Assert Assert.Equal(-100, endpoint.Order); Directory.Delete(webRoot, true); } [Fact] public async Task EndpointOrder_UsesValueFromManifest_WhenOrderIsSet() { // Arrange var appName = nameof(EndpointOrder_UsesValueFromManifest_WhenOrderIsSet); var (contentRoot, webRoot) = ConfigureAppPaths(appName); Directory.CreateDirectory(webRoot); var manifestPath = Path.Combine(AppContext.BaseDirectory, $"{appName}.staticwebassets.endpoints.json"); var hash = GetEtag("Hello, World!"); var lastModified = DateTimeOffset.UtcNow; File.WriteAllText(Path.Combine(webRoot, "index.html"), "Hello, World!"); var manifest = new StaticAssetsManifest() { Version = 1, ManifestType = "Build", Endpoints = [ new StaticAssetDescriptor { Route = "index.html", AssetPath = "index.html", Selectors = [], Properties = [new("integrity", $"sha256-{hash}")], ResponseHeaders = [ new("Accept-Ranges", "bytes"), new("Content-Length", "Hello, World!".Length.ToString(CultureInfo.InvariantCulture)), new("Content-Type", "text/html"), new("ETag", $"\"{hash}\""), new("Last-Modified", lastModified.ToString("ddd, dd MMM yyyy HH:mm:ss 'GMT'", CultureInfo.InvariantCulture)), ], Order = "2147483647" }, new StaticAssetDescriptor { Route = "other.html", AssetPath = "index.html", Selectors = [], Properties = [new("integrity", $"sha256-{hash}")], ResponseHeaders = [ new("Accept-Ranges", "bytes"), new("Content-Length", "Hello, World!".Length.ToString(CultureInfo.InvariantCulture)), new("Content-Type", "text/html"), new("ETag", $"\"{hash}\""), new("Last-Modified", lastModified.ToString("ddd, dd MMM yyyy HH:mm:ss 'GMT'", CultureInfo.InvariantCulture)), ], } ] }; { using var stream = File.Create(manifestPath); using var writer = new Utf8JsonWriter(stream); JsonSerializer.Serialize(writer, manifest); } var appBuilder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions { ApplicationName = appName, ContentRootPath = contentRoot, EnvironmentName = "Development", WebRootPath = webRoot }); appBuilder.WebHost.ConfigureServices(services => { services.AddRouting(); }); appBuilder.WebHost.UseTestServer(); var app = appBuilder.Build(); app.UseRouting(); app.UseEndpoints(endpoints => { endpoints.MapStaticAssets(); }); await app.StartAsync(); // Act var endpoints = app.Services.GetRequiredService<EndpointDataSource>().Endpoints .OfType<RouteEndpoint>() .ToList(); var indexEndpoint = endpoints.Single(e => e.RoutePattern.RawText == "index.html"); var otherEndpoint = endpoints.Single(e => e.RoutePattern.RawText == "other.html"); // Assert Assert.Equal(2147483647, indexEndpoint.Order); Assert.Equal(-100, otherEndpoint.Order); Directory.Delete(webRoot, true); } [Fact] public void TruncateToSeconds_RemovesSubsecondComponents() { var original = new DateTimeOffset(2023, 5, 15, 10, 30, 45, 123, TimeSpan.FromHours(2)); var truncated = StaticAssetDevelopmentRuntimeHandler.TruncateToSeconds(original); Assert.Equal(2023, truncated.Year); Assert.Equal(5, truncated.Month); Assert.Equal(15, truncated.Day); Assert.Equal(10, truncated.Hour); Assert.Equal(30, truncated.Minute); Assert.Equal(45, truncated.Second); Assert.Equal(0, truncated.Millisecond); Assert.Equal(TimeSpan.FromHours(2), truncated.Offset); } [Fact] public void TruncateToSeconds_PreservesUtcOffset() { var utcDateTime = new DateTimeOffset(2023, 5, 15, 10, 30, 45, 500, TimeSpan.Zero); var truncated = StaticAssetDevelopmentRuntimeHandler.TruncateToSeconds(utcDateTime); Assert.Equal(TimeSpan.Zero, truncated.Offset); Assert.Equal(0, truncated.Millisecond); } [Fact] public void TruncateToSeconds_MatchesHttpDateFormat() { var original = new DateTimeOffset(2023, 5, 15, 10, 30, 45, 789, TimeSpan.Zero); var httpFormatted = original.ToString("ddd, dd MMM yyyy HH:mm:ss 'GMT'", CultureInfo.InvariantCulture); var parsed = DateTimeOffset.Parse(httpFormatted, CultureInfo.InvariantCulture); var truncated = StaticAssetDevelopmentRuntimeHandler.TruncateToSeconds(original); Assert.Equal(parsed, truncated); } }