// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; using System.Collections.Generic; using System.IO; using System.IO.Compression; using System.IO.Pipelines; using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.TestHost; using Microsoft.AspNetCore.Testing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Testing; using Microsoft.Net.Http.Headers; using Xunit; namespace Microsoft.AspNetCore.ResponseCompression.Tests { public class ResponseCompressionMiddlewareTest { private const string TextPlain = "text/plain"; public static IEnumerable SupportedEncodings => TestData.Select(x => new object[] { x.EncodingName }); public static IEnumerable SupportedEncodingsWithBodyLength => TestData.Select(x => new object[] { x.EncodingName, x.ExpectedBodyLength }); private static IEnumerable TestData { get { yield return new EncodingTestData("gzip", expectedBodyLength: 30); yield return new EncodingTestData("br", expectedBodyLength: 21); } } [Fact] public void Options_HttpsDisabledByDefault() { var options = new ResponseCompressionOptions(); Assert.False(options.EnableForHttps); } [Fact] public async Task Request_NoAcceptEncoding_Uncompressed() { var (response, logMessages) = await InvokeMiddleware(100, requestAcceptEncodings: null, responseType: TextPlain); CheckResponseNotCompressed(response, expectedBodyLength: 100, sendVaryHeader: false); AssertLog(logMessages.Single(), LogLevel.Debug, "No response compression available, the Accept-Encoding header is missing or invalid."); } [Fact] public async Task Request_AcceptGzipDeflate_CompressedGzip() { var (response, logMessages) = await InvokeMiddleware(100, requestAcceptEncodings: new[] { "gzip", "deflate" }, responseType: TextPlain); CheckResponseCompressed(response, expectedBodyLength: 30, expectedEncoding: "gzip"); AssertCompressedWithLog(logMessages, "gzip"); } [Fact] public async Task Request_AcceptBrotli_CompressedBrotli() { var (response, logMessages) = await InvokeMiddleware(100, requestAcceptEncodings: new[] { "br" }, responseType: TextPlain); CheckResponseCompressed(response, expectedBodyLength: 21, expectedEncoding: "br"); AssertCompressedWithLog(logMessages, "br"); } [Theory] [InlineData("gzip", "br")] [InlineData("br", "gzip")] public async Task Request_AcceptMixed_CompressedBrotli(string encoding1, string encoding2) { var (response, logMessages) = await InvokeMiddleware(100, new[] { encoding1, encoding2 }, responseType: TextPlain); CheckResponseCompressed(response, expectedBodyLength: 21, expectedEncoding: "br"); AssertCompressedWithLog(logMessages, "br"); } [Theory] [InlineData("gzip", "br")] [InlineData("br", "gzip")] public async Task Request_AcceptMixed_ConfiguredOrder_CompressedGzip(string encoding1, string encoding2) { void Configure(ResponseCompressionOptions options) { options.Providers.Add(); options.Providers.Add(); } var (response, logMessages) = await InvokeMiddleware(100, new[] { encoding1, encoding2 }, responseType: TextPlain, configure: Configure); CheckResponseCompressed(response, expectedBodyLength: 30, expectedEncoding: "gzip"); AssertCompressedWithLog(logMessages, "gzip"); } [Fact] public async Task Request_AcceptUnknown_NotCompressed() { var (response, logMessages) = await InvokeMiddleware(100, requestAcceptEncodings: new[] { "unknown" }, responseType: TextPlain); CheckResponseNotCompressed(response, expectedBodyLength: 100, sendVaryHeader: true); Assert.Equal(3, logMessages.Count); AssertLog(logMessages.First(), LogLevel.Trace, "This request accepts compression."); AssertLog(logMessages.Skip(1).First(), LogLevel.Trace, "Response compression is available for this Content-Type."); AssertLog(logMessages.Skip(2).First(), LogLevel.Debug, "No matching response compression provider found."); } [Theory] [InlineData("text/plain")] [InlineData("text/PLAIN")] [InlineData("text/plain; charset=ISO-8859-4")] [InlineData("text/plain ; charset=ISO-8859-4")] public async Task ContentType_WithCharset_Compress(string contentType) { var (response, logMessages) = await InvokeMiddleware(uncompressedBodyLength: 100, requestAcceptEncodings: new[] { "gzip" }, contentType); CheckResponseCompressed(response, expectedBodyLength: 30, expectedEncoding: "gzip"); AssertCompressedWithLog(logMessages, "gzip"); } [Fact] public async Task GZipCompressionProvider_OptionsSetInDI_Compress() { var builder = new WebHostBuilder() .ConfigureServices(services => { services.Configure(options => options.Level = CompressionLevel.NoCompression); services.AddResponseCompression(); }) .Configure(app => { app.UseResponseCompression(); app.Run(context => { context.Response.Headers[HeaderNames.ContentMD5] = "MD5"; context.Response.ContentType = TextPlain; return context.Response.WriteAsync(new string('a', 100)); }); }); var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, ""); request.Headers.AcceptEncoding.ParseAdd("gzip"); var response = await client.SendAsync(request); CheckResponseCompressed(response, expectedBodyLength: 133, expectedEncoding: "gzip"); } [Theory] [InlineData("")] [InlineData("text/plain2")] public async Task MimeTypes_OtherContentTypes_NoMatch(string contentType) { var (response, logMessages) = await InvokeMiddleware(uncompressedBodyLength: 100, requestAcceptEncodings: new[] { "gzip" }, contentType); CheckResponseNotCompressed(response, expectedBodyLength: 100, sendVaryHeader: false); Assert.Equal(2, logMessages.Count); AssertLog(logMessages.First(), LogLevel.Trace, "This request accepts compression."); var expected = string.IsNullOrEmpty(contentType) ? "(null)" : contentType; AssertLog(logMessages.Skip(1).First(), LogLevel.Debug, $"Response compression is not enabled for the Content-Type '{expected}'."); } [Theory] [InlineData(null, null, "text/plain", true)] [InlineData(null, new string[0], "text/plain", true)] [InlineData(null, new[] { "TEXT/plain" }, "text/plain", false)] [InlineData(null, new[] { "TEXT/*" }, "text/plain", true)] [InlineData(null, new[] { "*/*" }, "text/plain", true)] [InlineData(new string[0], null, "text/plain", true)] [InlineData(new string[0], new string[0], "text/plain", true)] [InlineData(new string[0], new[] { "TEXT/plain" }, "text/plain", false)] [InlineData(new string[0], new[] { "TEXT/*" }, "text/plain", true)] [InlineData(new string[0], new[] { "*/*" }, "text/plain", true)] [InlineData(new[] { "TEXT/plain" }, null, "text/plain", true)] [InlineData(new[] { "TEXT/plain" }, new string[0], "text/plain", true)] [InlineData(new[] { "TEXT/plain" }, new[] { "TEXT/plain" }, "text/plain", false)] [InlineData(new[] { "TEXT/plain" }, new[] { "TEXT/*" }, "text/plain", true)] [InlineData(new[] { "TEXT/plain" }, new[] { "*/*" }, "text/plain", true)] [InlineData(new[] { "TEXT/*" }, null, "text/plain", true)] [InlineData(new[] { "TEXT/*" }, new string[0], "text/plain", true)] [InlineData(new[] { "TEXT/*" }, new[] { "TEXT/plain" }, "text/plain", false)] [InlineData(new[] { "TEXT/*" }, new[] { "TEXT/*" }, "text/plain", false)] [InlineData(new[] { "TEXT/*" }, new[] { "*/*" }, "text/plain", true)] [InlineData(new[] { "*/*" }, null, "text/plain", true)] [InlineData(new[] { "*/*" }, new string[0], "text/plain", true)] [InlineData(new[] { "*/*" }, new[] { "TEXT/plain" }, "text/plain", false)] [InlineData(new[] { "*/*" }, new[] { "TEXT/*" }, "text/plain", false)] [InlineData(new[] { "*/*" }, new[] { "*/*" }, "text/plain", true)] [InlineData(null, null, "text/plain2", false)] [InlineData(null, new string[0], "text/plain2", false)] [InlineData(null, new[] { "TEXT/plain" }, "text/plain2", false)] [InlineData(null, new[] { "TEXT/*" }, "text/plain2", false)] [InlineData(null, new[] { "*/*" }, "text/plain2", false)] [InlineData(new string[0], null, "text/plain2", false)] [InlineData(new string[0], new string[0], "text/plain2", false)] [InlineData(new string[0], new[] { "TEXT/plain" }, "text/plain2", false)] [InlineData(new string[0], new[] { "TEXT/*" }, "text/plain2", false)] [InlineData(new string[0], new[] { "*/*" }, "text/plain2", false)] [InlineData(new[] { "TEXT/plain" }, null, "text/plain2", false)] [InlineData(new[] { "TEXT/plain" }, new string[0], "text/plain2", false)] [InlineData(new[] { "TEXT/plain" }, new[] { "TEXT/plain" }, "text/plain2", false)] [InlineData(new[] { "TEXT/plain" }, new[] { "TEXT/*" }, "text/plain2", false)] [InlineData(new[] { "TEXT/plain" }, new[] { "*/*" }, "text/plain2", false)] [InlineData(new[] { "TEXT/*" }, null, "text/plain2", true)] [InlineData(new[] { "TEXT/*" }, new string[0], "text/plain2", true)] [InlineData(new[] { "TEXT/*" }, new[] { "TEXT/plain" }, "text/plain2", true)] [InlineData(new[] { "TEXT/*" }, new[] { "TEXT/*" }, "text/plain2", false)] [InlineData(new[] { "TEXT/*" }, new[] { "*/*" }, "text/plain2", true)] [InlineData(new[] { "*/*" }, null, "text/plain2", true)] [InlineData(new[] { "*/*" }, new string[0], "text/plain2", true)] [InlineData(new[] { "*/*" }, new[] { "TEXT/plain" }, "text/plain2", true)] [InlineData(new[] { "*/*" }, new[] { "TEXT/*" }, "text/plain2", false)] [InlineData(new[] { "*/*" }, new[] { "*/*" }, "text/plain2", true)] public async Task MimeTypes_IncludedAndExcluded( string[] mimeTypes, string[] excludedMimeTypes, string contentType, bool compress ) { var (response, logMessages) = await InvokeMiddleware(uncompressedBodyLength: 100, requestAcceptEncodings: new[] { "gzip" }, contentType, configure: options => { options.MimeTypes = mimeTypes; options.ExcludedMimeTypes = excludedMimeTypes; }); if (compress) { CheckResponseCompressed(response, expectedBodyLength: 30, expectedEncoding: "gzip"); AssertCompressedWithLog(logMessages, "gzip"); } else { CheckResponseNotCompressed(response, expectedBodyLength: 100, sendVaryHeader: false); Assert.Equal(2, logMessages.Count); AssertLog(logMessages.First(), LogLevel.Trace, "This request accepts compression."); AssertLog(logMessages.Skip(1).First(), LogLevel.Debug, $"Response compression is not enabled for the Content-Type '{contentType}'."); } } [Fact] public async Task NoIncludedMimeTypes_UseDefaults() { var (response, logMessages) = await InvokeMiddleware(uncompressedBodyLength: 100, requestAcceptEncodings: new[] { "gzip" }, TextPlain, configure: options => { options.ExcludedMimeTypes = new[] { "text/*" }; }); CheckResponseCompressed(response, expectedBodyLength: 30, expectedEncoding: "gzip"); AssertCompressedWithLog(logMessages, "gzip"); } [Theory] [InlineData("")] [InlineData("text/plain")] [InlineData("text/PLAIN")] [InlineData("text/plain; charset=ISO-8859-4")] [InlineData("text/plain ; charset=ISO-8859-4")] [InlineData("text/plain2")] public async Task NoBody_NotCompressed(string contentType) { var builder = new WebHostBuilder() .ConfigureServices(services => { services.AddResponseCompression(); }) .Configure(app => { app.UseResponseCompression(); app.Run(context => { context.Response.Headers[HeaderNames.ContentMD5] = "MD5"; context.Response.ContentType = contentType; return Task.FromResult(0); }); }); var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, ""); request.Headers.AcceptEncoding.ParseAdd("gzip"); var response = await client.SendAsync(request); CheckResponseNotCompressed(response, expectedBodyLength: 0, sendVaryHeader: false); } [Fact] public async Task Request_AcceptStar_Compressed() { var (response, logMessages) = await InvokeMiddleware(100, requestAcceptEncodings: new[] { "*" }, responseType: TextPlain); CheckResponseCompressed(response, expectedBodyLength: 21, expectedEncoding: "br"); AssertCompressedWithLog(logMessages, "br"); } [Fact] public async Task Request_AcceptIdentity_NotCompressed() { var (response, logMessages) = await InvokeMiddleware(100, requestAcceptEncodings: new[] { "identity" }, responseType: TextPlain); CheckResponseNotCompressed(response, expectedBodyLength: 100, sendVaryHeader: true); Assert.Equal(3, logMessages.Count); AssertLog(logMessages.First(), LogLevel.Trace, "This request accepts compression."); AssertLog(logMessages.Skip(1).First(), LogLevel.Trace, "Response compression is available for this Content-Type."); AssertLog(logMessages.Skip(2).First(), LogLevel.Debug, "No matching response compression provider found."); } [Theory] [InlineData(new[] { "identity;q=0.5", "gzip;q=1" }, 30)] [InlineData(new[] { "identity;q=0", "gzip;q=0.8" }, 30)] [InlineData(new[] { "identity;q=0.5", "gzip" }, 30)] public async Task Request_AcceptWithHigherCompressionQuality_Compressed(string[] acceptEncodings, int expectedBodyLength) { var (response, logMessages) = await InvokeMiddleware(100, requestAcceptEncodings: acceptEncodings, responseType: TextPlain); CheckResponseCompressed(response, expectedBodyLength: expectedBodyLength, expectedEncoding: "gzip"); AssertCompressedWithLog(logMessages, "gzip"); } [Theory] [InlineData(new[] { "gzip;q=0.5", "identity;q=0.8" }, 100)] public async Task Request_AcceptWithhigherIdentityQuality_NotCompressed(string[] acceptEncodings, int expectedBodyLength) { var (response, logMessages) = await InvokeMiddleware(100, requestAcceptEncodings: acceptEncodings, responseType: TextPlain); CheckResponseNotCompressed(response, expectedBodyLength: expectedBodyLength, sendVaryHeader: true); Assert.Equal(3, logMessages.Count); AssertLog(logMessages.First(), LogLevel.Trace, "This request accepts compression."); AssertLog(logMessages.Skip(1).First(), LogLevel.Trace, "Response compression is available for this Content-Type."); AssertLog(logMessages.Skip(2).First(), LogLevel.Debug, "No matching response compression provider found."); } [Fact] public async Task Response_UnknownMimeType_NotCompressed() { var (response, logMessages) = await InvokeMiddleware(100, requestAcceptEncodings: new[] { "gzip" }, responseType: "text/custom"); CheckResponseNotCompressed(response, expectedBodyLength: 100, sendVaryHeader: false); Assert.Equal(2, logMessages.Count); AssertLog(logMessages.First(), LogLevel.Trace, "This request accepts compression."); AssertLog(logMessages.Skip(1).First(), LogLevel.Debug, "Response compression is not enabled for the Content-Type 'text/custom'."); } [Fact] public async Task Response_WithContentRange_NotCompressed() { var (response, logMessages) = await InvokeMiddleware(50, requestAcceptEncodings: new[] { "gzip" }, responseType: TextPlain, addResponseAction: (r) => { r.Headers[HeaderNames.ContentRange] = "1-2/*"; }); CheckResponseNotCompressed(response, expectedBodyLength: 50, sendVaryHeader: false); Assert.Equal(2, logMessages.Count); AssertLog(logMessages.First(), LogLevel.Trace, "This request accepts compression."); AssertLog(logMessages.Skip(1).First(), LogLevel.Debug, "Response compression disabled due to the Content-Range header."); } [Fact] public async Task Response_WithContentEncodingAlreadySet_NotReCompressed() { var otherContentEncoding = "something"; var (response, logMessages) = await InvokeMiddleware(50, requestAcceptEncodings: new[] { "gzip" }, responseType: TextPlain, addResponseAction: (r) => { r.Headers[HeaderNames.ContentEncoding] = otherContentEncoding; }); Assert.True(response.Content.Headers.ContentEncoding.Contains(otherContentEncoding)); Assert.False(response.Content.Headers.ContentEncoding.Contains("gzip")); Assert.Equal(50, response.Content.Headers.ContentLength); Assert.Equal(2, logMessages.Count); AssertLog(logMessages.First(), LogLevel.Trace, "This request accepts compression."); AssertLog(logMessages.Skip(1).First(), LogLevel.Debug, "Response compression disabled due to the Content-Encoding header."); } [Theory] [InlineData(false, 100)] [InlineData(true, 30)] public async Task Request_Https_CompressedIfEnabled(bool enableHttps, int expectedLength) { var sink = new TestSink( TestSink.EnableWithTypeName, TestSink.EnableWithTypeName); var loggerFactory = new TestLoggerFactory(sink, enabled: true); var builder = new WebHostBuilder() .ConfigureServices(services => { services.AddSingleton(loggerFactory); services.AddResponseCompression(options => { options.EnableForHttps = enableHttps; options.MimeTypes = new[] { TextPlain }; }); }) .Configure(app => { app.UseResponseCompression(); app.Run(context => { context.Response.ContentType = TextPlain; return context.Response.WriteAsync(new string('a', 100)); }); }); var server = new TestServer(builder) { BaseAddress = new Uri("https://localhost/") }; var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, ""); request.Headers.AcceptEncoding.ParseAdd("gzip"); var response = await client.SendAsync(request); Assert.Equal(expectedLength, response.Content.ReadAsByteArrayAsync().Result.Length); var logMessages = sink.Writes.ToList(); if (enableHttps) { AssertCompressedWithLog(logMessages, "gzip"); } else { AssertLog(logMessages.Skip(1).Single(), LogLevel.Debug, "No response compression available for HTTPS requests. See ResponseCompressionOptions.EnableForHttps."); } } [Theory] [InlineData(HttpsCompressionMode.Default, 100)] [InlineData(HttpsCompressionMode.DoNotCompress, 100)] [InlineData(HttpsCompressionMode.Compress, 30)] public async Task Request_Https_CompressedIfOptIn(HttpsCompressionMode mode, int expectedLength) { var sink = new TestSink( TestSink.EnableWithTypeName, TestSink.EnableWithTypeName); var loggerFactory = new TestLoggerFactory(sink, enabled: true); var builder = new WebHostBuilder() .ConfigureServices(services => { services.AddSingleton(loggerFactory); services.AddResponseCompression(options => { options.EnableForHttps = false; options.MimeTypes = new[] { TextPlain }; }); }) .Configure(app => { app.UseResponseCompression(); app.Run(context => { var feature = context.Features.Get(); feature.Mode = mode; context.Response.ContentType = TextPlain; return context.Response.WriteAsync(new string('a', 100)); }); }); var server = new TestServer(builder) { BaseAddress = new Uri("https://localhost/") }; var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, ""); request.Headers.AcceptEncoding.ParseAdd("gzip"); var response = await client.SendAsync(request); Assert.Equal(expectedLength, response.Content.ReadAsByteArrayAsync().Result.Length); var logMessages = sink.Writes.ToList(); if (mode == HttpsCompressionMode.Compress) { AssertCompressedWithLog(logMessages, "gzip"); } else { AssertLog(logMessages.Skip(1).Single(), LogLevel.Debug, "No response compression available for HTTPS requests. See ResponseCompressionOptions.EnableForHttps."); } } [Theory] [InlineData(HttpsCompressionMode.Default, 30)] [InlineData(HttpsCompressionMode.Compress, 30)] [InlineData(HttpsCompressionMode.DoNotCompress, 100)] public async Task Request_Https_NotCompressedIfOptOut(HttpsCompressionMode mode, int expectedLength) { var sink = new TestSink( TestSink.EnableWithTypeName, TestSink.EnableWithTypeName); var loggerFactory = new TestLoggerFactory(sink, enabled: true); var builder = new WebHostBuilder() .ConfigureServices(services => { services.AddSingleton(loggerFactory); services.AddResponseCompression(options => { options.EnableForHttps = true; options.MimeTypes = new[] { TextPlain }; }); }) .Configure(app => { app.UseResponseCompression(); app.Run(context => { var feature = context.Features.Get(); feature.Mode = mode; context.Response.ContentType = TextPlain; return context.Response.WriteAsync(new string('a', 100)); }); }); var server = new TestServer(builder) { BaseAddress = new Uri("https://localhost/") }; var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, ""); request.Headers.AcceptEncoding.ParseAdd("gzip"); var response = await client.SendAsync(request); Assert.Equal(expectedLength, response.Content.ReadAsByteArrayAsync().Result.Length); var logMessages = sink.Writes.ToList(); if (mode == HttpsCompressionMode.DoNotCompress) { AssertLog(logMessages.Skip(1).Single(), LogLevel.Debug, "No response compression available for HTTPS requests. See ResponseCompressionOptions.EnableForHttps."); } else { AssertCompressedWithLog(logMessages, "gzip"); } } [Theory] [MemberData(nameof(SupportedEncodingsWithBodyLength))] public async Task FlushHeaders_SendsHeaders_Compresses(string encoding, int expectedBodyLength) { var responseReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var builder = new WebHostBuilder() .ConfigureServices(services => { services.AddResponseCompression(); }) .Configure(app => { app.UseResponseCompression(); app.Run(async context => { context.Response.Headers[HeaderNames.ContentMD5] = "MD5"; context.Response.ContentType = TextPlain; context.Response.Body.Flush(); await responseReceived.Task.TimeoutAfter(TimeSpan.FromSeconds(3)); await context.Response.WriteAsync(new string('a', 100)); }); }); var server = new TestServer(builder) { AllowSynchronousIO = true // needed for synchronous flush }; var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, ""); request.Headers.AcceptEncoding.ParseAdd(encoding); var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); responseReceived.SetResult(0); await response.Content.LoadIntoBufferAsync(); CheckResponseCompressed(response, expectedBodyLength, encoding); } [Theory] [MemberData(nameof(SupportedEncodingsWithBodyLength))] public async Task FlushAsyncHeaders_SendsHeaders_Compresses(string encoding, int expectedBodyLength) { var responseReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var builder = new WebHostBuilder() .ConfigureServices(services => { services.AddResponseCompression(); }) .Configure(app => { app.UseResponseCompression(); app.Run(async context => { context.Response.Headers[HeaderNames.ContentMD5] = "MD5"; context.Response.ContentType = TextPlain; await context.Response.Body.FlushAsync(); await responseReceived.Task.TimeoutAfter(TimeSpan.FromSeconds(3)); await context.Response.WriteAsync(new string('a', 100)); }); }); var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, ""); request.Headers.AcceptEncoding.ParseAdd(encoding); var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); responseReceived.SetResult(0); await response.Content.LoadIntoBufferAsync(); CheckResponseCompressed(response, expectedBodyLength, encoding); } [Theory] [MemberData(nameof(SupportedEncodings))] public async Task FlushBody_CompressesAndFlushes(string encoding) { var responseReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var builder = new WebHostBuilder() .ConfigureServices(services => { services.AddResponseCompression(); }) .Configure(app => { app.UseResponseCompression(); app.Run(async context => { var feature = context.Features.Get(); if (feature != null) { feature.AllowSynchronousIO = true; } context.Response.Headers[HeaderNames.ContentMD5] = "MD5"; context.Response.ContentType = TextPlain; context.Response.Body.Write(new byte[10], 0, 10); context.Response.Body.Flush(); await responseReceived.Task.TimeoutAfter(TimeSpan.FromSeconds(3)); context.Response.Body.Write(new byte[90], 0, 90); }); }); var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, ""); request.Headers.AcceptEncoding.ParseAdd(encoding); var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); Assert.False(response.Content.Headers.TryGetValues(HeaderNames.ContentMD5, out _)); Assert.Single(response.Content.Headers.ContentEncoding, encoding); var body = await response.Content.ReadAsStreamAsync(); var read = await body.ReadAsync(new byte[100], 0, 100); Assert.True(read > 0); responseReceived.SetResult(0); read = await body.ReadAsync(new byte[100], 0, 100); Assert.True(read > 0); } [Theory] [MemberData(nameof(SupportedEncodings))] public async Task FlushAsyncBody_CompressesAndFlushes(string encoding) { var responseReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var builder = new WebHostBuilder() .ConfigureServices(services => { services.AddResponseCompression(); }) .Configure(app => { app.UseResponseCompression(); app.Run(async context => { context.Response.Headers[HeaderNames.ContentMD5] = "MD5"; context.Response.ContentType = TextPlain; await context.Response.WriteAsync(new string('a', 10)); await context.Response.Body.FlushAsync(); await responseReceived.Task.TimeoutAfter(TimeSpan.FromSeconds(3)); await context.Response.WriteAsync(new string('a', 90)); }); }); var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, ""); request.Headers.AcceptEncoding.ParseAdd(encoding); var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); Assert.False(response.Content.Headers.TryGetValues(HeaderNames.ContentMD5, out _)); Assert.Single(response.Content.Headers.ContentEncoding, encoding); var body = await response.Content.ReadAsStreamAsync(); var read = await body.ReadAsync(new byte[100], 0, 100); Assert.True(read > 0); responseReceived.SetResult(0); read = await body.ReadAsync(new byte[100], 0, 100); Assert.True(read > 0); } [Theory] [MemberData(nameof(SupportedEncodings))] public async Task TrickleWriteAndFlush_FlushesEachWrite(string encoding) { var responseReceived = new[] { new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously), new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously), new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously), new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously), new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously), }; var builder = new WebHostBuilder() .ConfigureServices(services => { services.AddResponseCompression(); }) .Configure(app => { app.UseResponseCompression(); app.Run(async context => { context.Response.Headers[HeaderNames.ContentMD5] = "MD5"; context.Response.ContentType = TextPlain; context.Features.Get().DisableBuffering(); var feature = context.Features.Get(); if (feature != null) { feature.AllowSynchronousIO = true; } foreach (var signal in responseReceived) { context.Response.Body.Write(new byte[1], 0, 1); context.Response.Body.Flush(); await signal.Task.TimeoutAfter(TimeSpan.FromSeconds(3)); } }); }); var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, ""); request.Headers.AcceptEncoding.ParseAdd(encoding); var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); Assert.False(response.Content.Headers.TryGetValues(HeaderNames.ContentMD5, out _)); Assert.Single(response.Content.Headers.ContentEncoding, encoding); var body = await response.Content.ReadAsStreamAsync(); foreach (var signal in responseReceived) { var read = await body.ReadAsync(new byte[100], 0, 100); Assert.True(read > 0); signal.SetResult(0); } } [Theory] [MemberData(nameof(SupportedEncodings))] public async Task TrickleWriteAndFlushAsync_FlushesEachWrite(string encoding) { var responseReceived = new[] { new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously), new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously), new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously), new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously), new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously), }; var builder = new WebHostBuilder() .ConfigureServices(services => { services.AddResponseCompression(); }) .Configure(app => { app.UseResponseCompression(); app.Run(async context => { context.Response.Headers[HeaderNames.ContentMD5] = "MD5"; context.Response.ContentType = TextPlain; context.Features.Get().DisableBuffering(); foreach (var signal in responseReceived) { await context.Response.WriteAsync("a"); await context.Response.Body.FlushAsync(); await signal.Task.TimeoutAfter(TimeSpan.FromSeconds(3)); } }); }); var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, ""); request.Headers.AcceptEncoding.ParseAdd(encoding); var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); Assert.False(response.Content.Headers.TryGetValues(HeaderNames.ContentMD5, out _)); Assert.Single(response.Content.Headers.ContentEncoding, encoding); var body = await response.Content.ReadAsStreamAsync(); foreach (var signal in responseReceived) { var read = await body.ReadAsync(new byte[100], 0, 100); Assert.True(read > 0); signal.SetResult(0); } } [Fact] public async Task SendFileAsync_DifferentContentType_NotBypassed() { FakeSendFileFeature fakeSendFile = null; var builder = new WebHostBuilder() .ConfigureServices(services => { services.AddResponseCompression(); }) .Configure(app => { app.Use((context, next) => { fakeSendFile = new FakeSendFileFeature(context.Features.Get()); context.Features.Set(fakeSendFile); return next(); }); app.UseResponseCompression(); app.Run(context => { context.Response.Headers[HeaderNames.ContentMD5] = "MD5"; context.Response.ContentType = "custom/type"; context.Response.ContentLength = 1024; var sendFile = context.Features.Get(); Assert.NotNull(sendFile); return sendFile.SendFileAsync("testfile1kb.txt", 0, null, CancellationToken.None); }); }); var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, ""); request.Headers.AcceptEncoding.ParseAdd("gzip"); var response = await client.SendAsync(request); CheckResponseNotCompressed(response, expectedBodyLength: 1024, sendVaryHeader: false); Assert.True(fakeSendFile.SendFileInvoked); } [Fact] public async Task SendFileAsync_FirstWrite_CompressesAndFlushes() { FakeSendFileFeature fakeSendFile = null; var builder = new WebHostBuilder() .ConfigureServices(services => { services.AddResponseCompression(); }) .Configure(app => { app.Use((context, next) => { fakeSendFile = new FakeSendFileFeature(context.Features.Get()); context.Features.Set(fakeSendFile); return next(); }); app.UseResponseCompression(); app.Run(context => { context.Response.Headers[HeaderNames.ContentMD5] = "MD5"; context.Response.ContentType = TextPlain; context.Response.ContentLength = 1024; var sendFile = context.Features.Get(); Assert.NotNull(sendFile); return sendFile.SendFileAsync("testfile1kb.txt", 0, null, CancellationToken.None); }); }); var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, ""); request.Headers.AcceptEncoding.ParseAdd("gzip"); var response = await client.SendAsync(request); CheckResponseCompressed(response, expectedBodyLength: 34, expectedEncoding: "gzip"); Assert.False(fakeSendFile.SendFileInvoked); } [Fact] public async Task SendFileAsync_AfterFirstWrite_CompressesAndFlushes() { FakeSendFileFeature fakeSendFile = null; var builder = new WebHostBuilder() .ConfigureServices(services => { services.AddResponseCompression(); }) .Configure(app => { app.Use((context, next) => { fakeSendFile = new FakeSendFileFeature(context.Features.Get()); context.Features.Set(fakeSendFile); return next(); }); app.UseResponseCompression(); app.Run(async context => { context.Response.Headers[HeaderNames.ContentMD5] = "MD5"; context.Response.ContentType = TextPlain; var feature = context.Features.Get(); await context.Response.WriteAsync(new string('a', 100)); await feature.SendFileAsync("testfile1kb.txt", 0, null, CancellationToken.None); }); }); var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, ""); request.Headers.AcceptEncoding.ParseAdd("gzip"); var response = await client.SendAsync(request); CheckResponseCompressed(response, expectedBodyLength: 46, expectedEncoding: "gzip"); Assert.False(fakeSendFile.SendFileInvoked); } [Theory] [MemberData(nameof(SupportedEncodings))] public async Task Dispose_SyncWriteOrFlushNotCalled(string encoding) { var responseReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var builder = new WebHostBuilder() .ConfigureServices(services => { services.AddResponseCompression(); }) .Configure(app => { app.Use((context, next) => { context.Response.Body = new NoSyncWrapperStream(context.Response.Body); return next(); }); app.UseResponseCompression(); app.Run(async context => { context.Response.Headers[HeaderNames.ContentMD5] = "MD5"; context.Response.ContentType = TextPlain; await context.Response.WriteAsync(new string('a', 10)); await context.Response.Body.FlushAsync(); await responseReceived.Task.TimeoutAfter(TimeSpan.FromSeconds(3)); await context.Response.WriteAsync(new string('a', 90)); }); }); var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, ""); request.Headers.AcceptEncoding.ParseAdd(encoding); var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); Assert.False(response.Content.Headers.TryGetValues(HeaderNames.ContentMD5, out _)); Assert.Single(response.Content.Headers.ContentEncoding, encoding); var body = await response.Content.ReadAsStreamAsync(); var read = await body.ReadAsync(new byte[100], 0, 100); Assert.True(read > 0); responseReceived.SetResult(0); read = await body.ReadAsync(new byte[100], 0, 100); Assert.True(read > 0); } private async Task<(HttpResponseMessage, List)> InvokeMiddleware( int uncompressedBodyLength, string[] requestAcceptEncodings, string responseType, Action addResponseAction = null, Action configure = null) { var sink = new TestSink( TestSink.EnableWithTypeName, TestSink.EnableWithTypeName); var loggerFactory = new TestLoggerFactory(sink, enabled: true); var builder = new WebHostBuilder() .ConfigureServices(services => { services.AddResponseCompression(configure ?? (_ => { })); services.AddSingleton(loggerFactory); }) .Configure(app => { app.UseResponseCompression(); app.Run(context => { context.Response.Headers[HeaderNames.ContentMD5] = "MD5"; context.Response.ContentType = responseType; addResponseAction?.Invoke(context.Response); return context.Response.WriteAsync(new string('a', uncompressedBodyLength)); }); }); var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, ""); for (var i = 0; i < requestAcceptEncodings?.Length; i++) { request.Headers.AcceptEncoding.Add(System.Net.Http.Headers.StringWithQualityHeaderValue.Parse(requestAcceptEncodings[i])); } return (await client.SendAsync(request), sink.Writes.ToList()); } private void CheckResponseCompressed(HttpResponseMessage response, int expectedBodyLength, string expectedEncoding) { var containsVaryAcceptEncoding = false; foreach (var value in response.Headers.GetValues(HeaderNames.Vary)) { if (value.Contains(HeaderNames.AcceptEncoding)) { containsVaryAcceptEncoding = true; break; } } Assert.True(containsVaryAcceptEncoding); Assert.False(response.Content.Headers.TryGetValues(HeaderNames.ContentMD5, out _)); Assert.Single(response.Content.Headers.ContentEncoding, expectedEncoding); Assert.Equal(expectedBodyLength, response.Content.Headers.ContentLength); } private void CheckResponseNotCompressed(HttpResponseMessage response, int expectedBodyLength, bool sendVaryHeader) { if (sendVaryHeader) { var containsVaryAcceptEncoding = false; foreach (var value in response.Headers.GetValues(HeaderNames.Vary)) { if (value.Contains(HeaderNames.AcceptEncoding)) { containsVaryAcceptEncoding = true; break; } } Assert.True(containsVaryAcceptEncoding); } else { Assert.False(response.Headers.Contains(HeaderNames.Vary)); } Assert.NotNull(response.Content.Headers.GetValues(HeaderNames.ContentMD5)); Assert.Empty(response.Content.Headers.ContentEncoding); Assert.Equal(expectedBodyLength, response.Content.Headers.ContentLength); } private void AssertLog(WriteContext log, LogLevel level, string message) { Assert.Equal(level, log.LogLevel); Assert.Equal(message, log.State.ToString()); } private void AssertCompressedWithLog(List logMessages, string provider) { Assert.Equal(3, logMessages.Count); AssertLog(logMessages.First(), LogLevel.Trace, "This request accepts compression."); AssertLog(logMessages.Skip(1).First(), LogLevel.Trace, "Response compression is available for this Content-Type."); AssertLog(logMessages.Skip(2).First(), LogLevel.Debug, $"The response will be compressed with '{provider}'."); } private class FakeSendFileFeature : IHttpResponseBodyFeature { public FakeSendFileFeature(IHttpResponseBodyFeature innerFeature) { InnerFeature = innerFeature; } public IHttpResponseBodyFeature InnerFeature { get; } public bool SendFileInvoked { get; set; } public Stream Stream => InnerFeature.Stream; public PipeWriter Writer => InnerFeature.Writer; public Task CompleteAsync() => InnerFeature.CompleteAsync(); public void DisableBuffering() => InnerFeature.DisableBuffering(); public Task SendFileAsync(string path, long offset, long? count, CancellationToken cancellation) { // This implementation should only be delegated to if compression is disabled. SendFileInvoked = true; return InnerFeature.SendFileAsync(path, offset, count, cancellation); } public Task StartAsync(CancellationToken token = default) => InnerFeature.StartAsync(token); } private readonly struct EncodingTestData { public EncodingTestData(string encodingName, int expectedBodyLength) { EncodingName = encodingName; ExpectedBodyLength = expectedBodyLength; } public string EncodingName { get; } public int ExpectedBodyLength { get; } } private class NoSyncWrapperStream : Stream { private Stream _body; public NoSyncWrapperStream(Stream body) { _body = body; } public override bool CanRead => _body.CanRead; public override bool CanSeek => _body.CanSeek; public override bool CanWrite => _body.CanWrite; public override long Length => _body.Length; public override long Position { get => throw new InvalidOperationException("This shouldn't be called"); set => throw new InvalidOperationException("This shouldn't be called"); } public override void Flush() { throw new InvalidOperationException("This shouldn't be called"); } public override int Read(byte[] buffer, int offset, int count) { throw new InvalidOperationException("This shouldn't be called"); } public override long Seek(long offset, SeekOrigin origin) { throw new InvalidOperationException("This shouldn't be called"); } public override void SetLength(long value) { throw new InvalidOperationException("This shouldn't be called"); } public override void Write(byte[] buffer, int offset, int count) { throw new InvalidOperationException("This shouldn't be called"); } public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) { return _body.WriteAsync(buffer, offset, count, cancellationToken); } public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) { return _body.WriteAsync(buffer, cancellationToken); } public override IAsyncResult BeginWrite(byte[] buffer, int offset, int count, AsyncCallback callback, object state) { return _body.BeginWrite(buffer, offset, count, callback, state); } public override void EndWrite(IAsyncResult asyncResult) { _body.EndWrite(asyncResult); } public override void Close() { throw new InvalidOperationException("This shouldn't be called"); } protected override void Dispose(bool disposing) { throw new InvalidOperationException("This shouldn't be called"); } public override ValueTask DisposeAsync() { return _body.DisposeAsync(); } public override void CopyTo(Stream destination, int bufferSize) { throw new InvalidOperationException("This shouldn't be called"); } public override Task FlushAsync(CancellationToken cancellationToken) { return _body.FlushAsync(cancellationToken); } } } }