From 164b45fdbc7f5bf095c9c773f1dd773b0df216bd Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Wed, 5 Apr 2023 14:13:16 +0200 Subject: [PATCH 01/24] WIP --- src/RestSharp/RestClient.Async.cs | 77 +++++++++++++++++-- .../RedirectTests.cs | 15 +++- .../Server/TestServer.cs | 10 +++ 3 files changed, 96 insertions(+), 6 deletions(-) diff --git a/src/RestSharp/RestClient.Async.cs b/src/RestSharp/RestClient.Async.cs index 9c99b9fd7..8feab2bd6 100644 --- a/src/RestSharp/RestClient.Async.cs +++ b/src/RestSharp/RestClient.Async.cs @@ -90,10 +90,6 @@ async Task ExecuteRequestAsync(RestRequest request, CancellationTo var httpMethod = AsHttpMethod(request.Method); var url = this.BuildUri(request); - using var message = new HttpRequestMessage(httpMethod, url) { Content = requestContent.BuildContent() }; - message.Headers.Host = Options.BaseHost; - message.Headers.CacheControl = request.CachePolicy ?? Options.CachePolicy; - using var timeoutCts = new CancellationTokenSource(request.Timeout > 0 ? request.Timeout : int.MaxValue); using var cts = CancellationTokenSource.CreateLinkedTokenSource(timeoutCts.Token, cancellationToken); @@ -116,7 +112,57 @@ async Task ExecuteRequestAsync(RestRequest request, CancellationTo await OnBeforeRequest(message).ConfigureAwait(false); try { - responseMessage = await HttpClient.SendAsync(message, request.CompletionOption, ct).ConfigureAwait(false); + // Make sure we have a cookie container if not provided in the request + var cookieContainer = request.CookieContainer ??= new CookieContainer(); + + var headers = new RequestHeaders() + .AddHeaders(request.Parameters) + .AddHeaders(DefaultParameters) + .AddAcceptHeader(AcceptedContentTypes) + .AddCookieHeaders(url, cookieContainer) + .AddCookieHeaders(url, Options.CookieContainer); + + HttpResponseMessage? responseMessage; + + while (true) { + using var requestContent = new RequestContent(this, request); + using var message = PrepareRequestMessage(httpMethod, url, requestContent, headers); + + if (request.OnBeforeRequest != null) await request.OnBeforeRequest(message).ConfigureAwait(false); + + responseMessage = await HttpClient.SendAsync(message, request.CompletionOption, ct).ConfigureAwait(false); + + if (request.OnAfterRequest != null) await request.OnAfterRequest(responseMessage).ConfigureAwait(false); + + if (!IsRedirect(responseMessage)) { + // || !Options.FollowRedirects) { + break; + } + + var location = responseMessage.Headers.Location; + + if (location == null) { + break; + } + + if (!location.IsAbsoluteUri) { + location = new Uri(url, location); + } + + if (responseMessage.StatusCode == HttpStatusCode.RedirectMethod) { + httpMethod = HttpMethod.Get; + } + + url = location; + + if (responseMessage.Headers.TryGetValues(KnownHeaders.SetCookie, out var cookiesHeader)) { + // ReSharper disable once PossibleMultipleEnumeration + cookieContainer.AddCookies(url, cookiesHeader); + // ReSharper disable once PossibleMultipleEnumeration + Options.CookieContainer?.AddCookies(url, cookiesHeader); + } + } + // Parse all the cookies from the response and update the cookie jar with cookies if (responseMessage.Headers.TryGetValues(KnownHeaders.SetCookie, out var cookiesHeader)) { // ReSharper disable once PossibleMultipleEnumeration @@ -162,6 +208,27 @@ async Task OnAfterRequest(HttpResponseMessage responseMessage) { } } + HttpRequestMessage PrepareRequestMessage(HttpMethod httpMethod, Uri url, RequestContent requestContent, RequestHeaders headers) { + var message = new HttpRequestMessage(httpMethod, url) { Content = requestContent.BuildContent() }; + message.Headers.Host = Options.BaseHost; + message.Headers.CacheControl = Options.CachePolicy; + message.AddHeaders(headers); + + return message; + } + + static bool IsRedirect(HttpResponseMessage responseMessage) + => responseMessage.StatusCode switch { + HttpStatusCode.MovedPermanently => true, + HttpStatusCode.SeeOther => true, + HttpStatusCode.TemporaryRedirect => true, + HttpStatusCode.Redirect => true, +#if NET + HttpStatusCode.PermanentRedirect => true, +#endif + _ => false + }; + record HttpResponse( HttpResponseMessage? ResponseMessage, Uri Url, diff --git a/test/RestSharp.Tests.Integrated/RedirectTests.cs b/test/RestSharp.Tests.Integrated/RedirectTests.cs index 47b4954a2..ec7c948bc 100644 --- a/test/RestSharp.Tests.Integrated/RedirectTests.cs +++ b/test/RestSharp.Tests.Integrated/RedirectTests.cs @@ -21,12 +21,14 @@ namespace RestSharp.Tests.Integrated; [Collection(nameof(TestServerCollection))] public class RedirectTests { readonly RestClient _client; + readonly string _host; public RedirectTests(TestServerFixture fixture) { var options = new RestClientOptions(fixture.Server.Url) { - FollowRedirects = true + FollowRedirects = false }; _client = new RestClient(options); + _host = _client.Options.BaseUrl!.Host; } [Fact] @@ -40,6 +42,17 @@ public async Task Can_Perform_GET_Async_With_Redirect() { response.Data!.Message.Should().Be(val); } + [Fact] + public async Task Can_Perform_GET_Async_With_Request_Cookies() { + var request = new RestRequest("get-cookies-redirect") { + CookieContainer = new CookieContainer(), + }; + request.CookieContainer.Add(new Cookie("cookie", "value", null, _host)); + request.CookieContainer.Add(new Cookie("cookie2", "value2", null, _host)); + var response = await _client.ExecuteAsync(request); + response.Content.Should().Be("[\"cookie=value\",\"cookie2=value2\"]"); + } + class Response { public string? Message { get; set; } } diff --git a/test/RestSharp.Tests.Integrated/Server/TestServer.cs b/test/RestSharp.Tests.Integrated/Server/TestServer.cs index dd075532a..c0ba471cb 100644 --- a/test/RestSharp.Tests.Integrated/Server/TestServer.cs +++ b/test/RestSharp.Tests.Integrated/Server/TestServer.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging; using RestSharp.Tests.Integrated.Server.Handlers; using RestSharp.Tests.Shared.Extensions; + // ReSharper disable ConvertClosureToMethodGroup namespace RestSharp.Tests.Integrated.Server; @@ -36,12 +37,21 @@ public HttpServer(ITestOutputHelper? output = null) { _app.MapGet("headers", HeaderHandlers.HandleHeaders); _app.MapGet("request-echo", async context => await context.Request.BodyReader.AsStream().CopyToAsync(context.Response.BodyWriter.AsStream())); _app.MapDelete("delete", () => new TestResponse { Message = "Works!" }); + _app.MapGet("redirect", () => Results.Redirect("/success", false, true)); // Cookies _app.MapGet("get-cookies", CookieHandlers.HandleCookies); _app.MapGet("set-cookies", CookieHandlers.HandleSetCookies); _app.MapGet("redirect", () => Results.Redirect("/success", false, true)); + _app.MapGet( + "get-cookies-redirect", + (HttpContext ctx) => { + ctx.Response.Cookies.Append("redirectCookie", "value1"); + return Results.Redirect("/get-cookies", false, true); + } + ); + // PUT _app.MapPut( ContentResource, From c6b39b5d1db4d378d9e248813309a423ce1fae55 Mon Sep 17 00:00:00 2001 From: tuttb Date: Tue, 25 Jul 2023 12:03:38 -0400 Subject: [PATCH 02/24] Improvements in processing redirects with cookie containers. --- src/RestSharp/RestClient.Async.cs | 80 ++++++++++++++++++++++++++++--- 1 file changed, 74 insertions(+), 6 deletions(-) diff --git a/src/RestSharp/RestClient.Async.cs b/src/RestSharp/RestClient.Async.cs index 8feab2bd6..d1432df0f 100644 --- a/src/RestSharp/RestClient.Async.cs +++ b/src/RestSharp/RestClient.Async.cs @@ -89,6 +89,7 @@ async Task ExecuteRequestAsync(RestRequest request, CancellationTo var httpMethod = AsHttpMethod(request.Method); var url = this.BuildUri(request); + var originalUrl = url; using var timeoutCts = new CancellationTokenSource(request.Timeout > 0 ? request.Timeout : int.MaxValue); using var cts = CancellationTokenSource.CreateLinkedTokenSource(timeoutCts.Token, cancellationToken); @@ -122,11 +123,19 @@ async Task ExecuteRequestAsync(RestRequest request, CancellationTo .AddCookieHeaders(url, cookieContainer) .AddCookieHeaders(url, Options.CookieContainer); - HttpResponseMessage? responseMessage; + bool foundCookies = false; + HttpResponseMessage? responseMessage = null; - while (true) { + do { using var requestContent = new RequestContent(this, request); - using var message = PrepareRequestMessage(httpMethod, url, requestContent, headers); + using var content = requestContent.BuildContent(); + + // If we found coookies during a redirect, + // we need to update the Cookie headers: + if (foundCookies) { + headers.AddCookieHeaders(cookieContainer, url); + } + using var message = PrepareRequestMessage(httpMethod, url, content, headers); if (request.OnBeforeRequest != null) await request.OnBeforeRequest(message).ConfigureAwait(false); @@ -149,19 +158,61 @@ async Task ExecuteRequestAsync(RestRequest request, CancellationTo location = new Uri(url, location); } + // Mirror HttpClient redirection behavior as of 07/25/2023: + // Per https://tools.ietf.org/html/rfc7231#section-7.1.2, a redirect location without a + // fragment should inherit the fragment from the original URI. + string requestFragment = originalUrl.Fragment; + if (!string.IsNullOrEmpty(requestFragment)) { + string redirectFragment = location.Fragment; + if (string.IsNullOrEmpty(redirectFragment)) { + location = new UriBuilder(location) { Fragment = requestFragment }.Uri; + } + } + + // Disallow automatic redirection from secure to non-secure schemes + // From HttpClient's RedirectHandler: + //if (HttpUtilities.IsSupportedSecureScheme(requestUri.Scheme) && !HttpUtilities.IsSupportedSecureScheme(location.Scheme)) { + // if (NetEventSource.Log.IsEnabled()) { + // TraceError($"Insecure https to http redirect from '{requestUri}' to '{location}' blocked.", response.RequestMessage!.GetHashCode()); + // } + // break; + //} + if (responseMessage.StatusCode == HttpStatusCode.RedirectMethod) { httpMethod = HttpMethod.Get; } + // Based on Wikipedia https://en.wikipedia.org/wiki/HTTP_302: + // Many web browsers implemented this code in a manner that violated this standard, changing + // the request type of the new request to GET, regardless of the type employed in the original request + // (e.g. POST). For this reason, HTTP/1.1 (RFC 2616) added the new status codes 303 and 307 to disambiguate + // between the two behaviours, with 303 mandating the change of request type to GET, and 307 preserving the + // request type as originally sent. Despite the greater clarity provided by this disambiguation, the 302 code + // is still employed in web frameworks to preserve compatibility with browsers that do not implement the HTTP/1.1 + // specification. + + // NOTE: Given the above, it is not surprising that HttpClient when AllowRedirect = true + // solves this problem by a helper method: + if (RedirectRequestRequiresForceGet(responseMessage.StatusCode, httpMethod)) { + httpMethod = HttpMethod.Get; + // HttpClient sets request.Content to null here: + // TODO: However... should we be allowed to modify Request like that here? + message.Content = null; + // HttpClient Redirect handler also does this: + //if (message.Headers.TansferEncodingChunked == true) { + // request.Headers.TransferEncodingChunked = false; + //} + } url = location; if (responseMessage.Headers.TryGetValues(KnownHeaders.SetCookie, out var cookiesHeader)) { + foundCookies = true; // ReSharper disable once PossibleMultipleEnumeration cookieContainer.AddCookies(url, cookiesHeader); // ReSharper disable once PossibleMultipleEnumeration Options.CookieContainer?.AddCookies(url, cookiesHeader); } - } + } while (true); // Parse all the cookies from the response and update the cookie jar with cookies if (responseMessage.Headers.TryGetValues(KnownHeaders.SetCookie, out var cookiesHeader)) { @@ -208,8 +259,25 @@ async Task OnAfterRequest(HttpResponseMessage responseMessage) { } } - HttpRequestMessage PrepareRequestMessage(HttpMethod httpMethod, Uri url, RequestContent requestContent, RequestHeaders headers) { - var message = new HttpRequestMessage(httpMethod, url) { Content = requestContent.BuildContent() }; + /// + /// Based on .net core RedirectHandler class: + /// https://github.com/dotnet/runtime/blob/main/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/RedirectHandler.cs + /// + /// + /// + /// + /// + private bool RedirectRequestRequiresForceGet(HttpStatusCode statusCode, HttpMethod httpMethod) { + return statusCode switch { + HttpStatusCode.Moved or HttpStatusCode.Found or HttpStatusCode.MultipleChoices + => httpMethod == HttpMethod.Post, + HttpStatusCode.SeeOther => httpMethod != HttpMethod.Get && httpMethod != HttpMethod.Head, + _ => false, + }; + } + + HttpRequestMessage PrepareRequestMessage(HttpMethod httpMethod, Uri url, HttpContent content, RequestHeaders headers) { + var message = new HttpRequestMessage(httpMethod, url) { Content = content }; message.Headers.Host = Options.BaseHost; message.Headers.CacheControl = Options.CachePolicy; message.AddHeaders(headers); From 00b718e39ba4cde45431a19204f87d0c91d3da91 Mon Sep 17 00:00:00 2001 From: tuttb Date: Tue, 25 Jul 2023 13:42:57 -0400 Subject: [PATCH 03/24] Added very first of many redirection cookie tests. --- test/RestSharp.Tests.Integrated/RedirectTests.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/test/RestSharp.Tests.Integrated/RedirectTests.cs b/test/RestSharp.Tests.Integrated/RedirectTests.cs index ec7c948bc..6b3badcba 100644 --- a/test/RestSharp.Tests.Integrated/RedirectTests.cs +++ b/test/RestSharp.Tests.Integrated/RedirectTests.cs @@ -53,6 +53,21 @@ public async Task Can_Perform_GET_Async_With_Request_Cookies() { response.Content.Should().Be("[\"cookie=value\",\"cookie2=value2\"]"); } + [Fact] + public async Task Can_Perform_POST_Async_With_RedirectionResponse_Cookies() { + var request = new RestRequest("/post/set-cookie-redirect") { + Method = Method.Post, + }; + + var response = await _client.ExecuteAsync(request); + // Verify the cookie exists from the POST: + response.Cookies.Count.Should().BeGreaterThan(0).And.Be(1); + response.Cookies[0].Name.Should().Be("redirectCookie"); + response.Cookies[0].Value.Should().Be("value1"); + // Make sure the redirected location spits out the correct content: + response.Content.Should().Be("[\"redirectCookie=value1\"]", "was successfully redirected to get-cookies"); + } + class Response { public string? Message { get; set; } } From 30eab16d0021c9b63d33b95f5053ed02e7d781b0 Mon Sep 17 00:00:00 2001 From: tuttb Date: Tue, 25 Jul 2023 13:42:57 -0400 Subject: [PATCH 04/24] Added very first of many redirection cookie tests. --- test/RestSharp.Tests.Integrated/Server/TestServer.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/RestSharp.Tests.Integrated/Server/TestServer.cs b/test/RestSharp.Tests.Integrated/Server/TestServer.cs index c0ba471cb..d7b545cf8 100644 --- a/test/RestSharp.Tests.Integrated/Server/TestServer.cs +++ b/test/RestSharp.Tests.Integrated/Server/TestServer.cs @@ -52,6 +52,13 @@ public HttpServer(ITestOutputHelper? output = null) { } ); + _app.MapPost( + "/post/set-cookie-redirect", + (HttpContext ctx) => { + ctx.Response.Cookies.Append("redirectCookie", "value1"); + return Results.Redirect("/get-cookies", permanent: false, preserveMethod: false); + }); + // PUT _app.MapPut( ContentResource, From 9c56c0609f0bc4a83b6ef9d5e239a8fab2789697 Mon Sep 17 00:00:00 2001 From: tuttb Date: Tue, 25 Jul 2023 14:07:38 -0400 Subject: [PATCH 05/24] Update previous redirect test case since, the redirected URL sends cookies as well... --- test/RestSharp.Tests.Integrated/RedirectTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/RestSharp.Tests.Integrated/RedirectTests.cs b/test/RestSharp.Tests.Integrated/RedirectTests.cs index 6b3badcba..2ecd3a718 100644 --- a/test/RestSharp.Tests.Integrated/RedirectTests.cs +++ b/test/RestSharp.Tests.Integrated/RedirectTests.cs @@ -43,14 +43,14 @@ public async Task Can_Perform_GET_Async_With_Redirect() { } [Fact] - public async Task Can_Perform_GET_Async_With_Request_Cookies() { + public async Task Can_Perform_GET_Async_With_Request_Cookies_And_RedirectCookie() { var request = new RestRequest("get-cookies-redirect") { CookieContainer = new CookieContainer(), }; request.CookieContainer.Add(new Cookie("cookie", "value", null, _host)); request.CookieContainer.Add(new Cookie("cookie2", "value2", null, _host)); var response = await _client.ExecuteAsync(request); - response.Content.Should().Be("[\"cookie=value\",\"cookie2=value2\"]"); + response.Content.Should().Be("[\"redirectCookie=value1\",\"cookie=value\",\"cookie2=value2\"]"); } [Fact] From 1f9a142ecbbfd333b949f5936838d9e2893a4d5c Mon Sep 17 00:00:00 2001 From: tuttb Date: Tue, 29 Aug 2023 00:00:00 -0400 Subject: [PATCH 06/24] Additional tests --- .../RedirectTests.cs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/test/RestSharp.Tests.Integrated/RedirectTests.cs b/test/RestSharp.Tests.Integrated/RedirectTests.cs index 2ecd3a718..1620f6024 100644 --- a/test/RestSharp.Tests.Integrated/RedirectTests.cs +++ b/test/RestSharp.Tests.Integrated/RedirectTests.cs @@ -68,6 +68,37 @@ public async Task Can_Perform_POST_Async_With_RedirectionResponse_Cookies() { response.Content.Should().Be("[\"redirectCookie=value1\"]", "was successfully redirected to get-cookies"); } + [Fact] + public async Task Can_Perform_POST_Async_With_SeeOtherRedirectionResponse_Cookies() { + var request = new RestRequest("/post/set-cookie-seeother") { + Method = Method.Post, + }; + + var response = await _client.ExecuteAsync(request); + // Verify the cookie exists from the POST: + response.Cookies.Count.Should().BeGreaterThan(0).And.Be(1); + response.Cookies[0].Name.Should().Be("redirectCookie"); + response.Cookies[0].Value.Should().Be("seeOtherValue1"); + // Make sure the redirected location spits out the correct content: + response.Content.Should().Be("[\"redirectCookie=seeOtherValue1\"]", "was successfully redirected to get-cookies"); + } + + [Fact] + public async Task Can_Perform_PUT_Async_With_RedirectionResponse_Cookies() { + var request = new RestRequest("/put/set-cookie-redirect") { + Method = Method.Put, + }; + + var response = await _client.ExecuteAsync(request); + // Verify the cookie exists from the PUT: + response.Cookies.Count.Should().BeGreaterThan(0).And.Be(1); + response.Cookies[0].Name.Should().Be("redirectCookie"); + response.Cookies[0].Value.Should().Be("putCookieValue1"); + // However, the redirection location should have been a 404: + // Make sure the redirected location spits out the correct content from PUT /get-cookies: + response.StatusCode.Should().Be(HttpStatusCode.MethodNotAllowed); + } + class Response { public string? Message { get; set; } } From d39f15c0e70ff3706b3f4ad527562c9a62160127 Mon Sep 17 00:00:00 2001 From: tuttb Date: Tue, 29 Aug 2023 00:00:55 -0400 Subject: [PATCH 07/24] more redirection/cookie related routes --- .../Server/TestServer.cs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/test/RestSharp.Tests.Integrated/Server/TestServer.cs b/test/RestSharp.Tests.Integrated/Server/TestServer.cs index d7b545cf8..0c32396b1 100644 --- a/test/RestSharp.Tests.Integrated/Server/TestServer.cs +++ b/test/RestSharp.Tests.Integrated/Server/TestServer.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging; using RestSharp.Tests.Integrated.Server.Handlers; using RestSharp.Tests.Shared.Extensions; +using System.Net; // ReSharper disable ConvertClosureToMethodGroup @@ -41,6 +42,11 @@ public HttpServer(ITestOutputHelper? output = null) { // Cookies _app.MapGet("get-cookies", CookieHandlers.HandleCookies); + _app.MapPut("get-cookies", + (HttpContext cxt) => { + // Make sure we get the status code we expect: + return Results.StatusCode(405); + }); _app.MapGet("set-cookies", CookieHandlers.HandleSetCookies); _app.MapGet("redirect", () => Results.Redirect("/success", false, true)); @@ -58,6 +64,18 @@ public HttpServer(ITestOutputHelper? output = null) { ctx.Response.Cookies.Append("redirectCookie", "value1"); return Results.Redirect("/get-cookies", permanent: false, preserveMethod: false); }); + _app.MapPost( + "/post/set-cookie-seeother", + (HttpContext ctx) => { + ctx.Response.Cookies.Append("redirectCookie", "seeOtherValue1"); + return new RedirectWithStatusCodeResult((int)HttpStatusCode.SeeOther, "/get-cookies"); + }); + _app.MapPut( + "/put/set-cookie-redirect", + (HttpContext ctx) => { + ctx.Response.Cookies.Append("redirectCookie", "putCookieValue1"); + return Results.Redirect("/get-cookies", permanent: false, preserveMethod: false); + }); // PUT _app.MapPut( From 653d0e0682fa1c5e7075251595d1f07b90ae5124 Mon Sep 17 00:00:00 2001 From: tuttb Date: Thu, 7 Sep 2023 00:53:58 -0400 Subject: [PATCH 08/24] Fix build error with respect to System.Web.HttpUtility.ParseQueryString.. wierd one.. --- src/RestSharp/RestClient.Async.cs | 16 +++++++--------- .../AuthenticationTests.cs | 3 ++- test/RestSharp.Tests/RestSharp.Tests.csproj | 8 ++++++++ 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/RestSharp/RestClient.Async.cs b/src/RestSharp/RestClient.Async.cs index d1432df0f..6bae75086 100644 --- a/src/RestSharp/RestClient.Async.cs +++ b/src/RestSharp/RestClient.Async.cs @@ -85,8 +85,6 @@ async Task ExecuteRequestAsync(RestRequest request, CancellationTo await authenticator.Authenticate(this, request).ConfigureAwait(false); } - using var requestContent = new RequestContent(this, request); - var httpMethod = AsHttpMethod(request.Method); var url = this.BuildUri(request); var originalUrl = url; @@ -133,7 +131,7 @@ async Task ExecuteRequestAsync(RestRequest request, CancellationTo // If we found coookies during a redirect, // we need to update the Cookie headers: if (foundCookies) { - headers.AddCookieHeaders(cookieContainer, url); + headers.AddCookieHeaders(url, cookieContainer); } using var message = PrepareRequestMessage(httpMethod, url, content, headers); @@ -205,21 +203,21 @@ async Task ExecuteRequestAsync(RestRequest request, CancellationTo url = location; - if (responseMessage.Headers.TryGetValues(KnownHeaders.SetCookie, out var cookiesHeader)) { + if (responseMessage.Headers.TryGetValues(KnownHeaders.SetCookie, out var cookiesHeader1)) { foundCookies = true; // ReSharper disable once PossibleMultipleEnumeration - cookieContainer.AddCookies(url, cookiesHeader); + cookieContainer.AddCookies(url, cookiesHeader1); // ReSharper disable once PossibleMultipleEnumeration - Options.CookieContainer?.AddCookies(url, cookiesHeader); + Options.CookieContainer?.AddCookies(url, cookiesHeader1); } } while (true); // Parse all the cookies from the response and update the cookie jar with cookies - if (responseMessage.Headers.TryGetValues(KnownHeaders.SetCookie, out var cookiesHeader)) { + if (responseMessage.Headers.TryGetValues(KnownHeaders.SetCookie, out var cookiesHeader2)) { // ReSharper disable once PossibleMultipleEnumeration - cookieContainer.AddCookies(url, cookiesHeader); + cookieContainer.AddCookies(url, cookiesHeader2); // ReSharper disable once PossibleMultipleEnumeration - Options.CookieContainer?.AddCookies(url, cookiesHeader); + Options.CookieContainer?.AddCookies(url, cookiesHeader2); } } catch (Exception ex) { diff --git a/test/RestSharp.InteractiveTests/AuthenticationTests.cs b/test/RestSharp.InteractiveTests/AuthenticationTests.cs index c9f9cd6d9..23692d9b7 100644 --- a/test/RestSharp.InteractiveTests/AuthenticationTests.cs +++ b/test/RestSharp.InteractiveTests/AuthenticationTests.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Text; using System.Web; using RestSharp.Authenticators; @@ -30,7 +31,7 @@ public static async Task Can_Authenticate_With_OAuth_Async_With_Callback(Twitter Assert.NotNull(response); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var qs = HttpUtility.ParseQueryString(response.Content); + var qs = HttpUtility.ParseQueryString(response.Content, Encoding.UTF8); var oauthToken = qs["oauth_token"]; var oauthTokenSecret = qs["oauth_token_secret"]; diff --git a/test/RestSharp.Tests/RestSharp.Tests.csproj b/test/RestSharp.Tests/RestSharp.Tests.csproj index eda460e5b..8f07f9ab3 100644 --- a/test/RestSharp.Tests/RestSharp.Tests.csproj +++ b/test/RestSharp.Tests/RestSharp.Tests.csproj @@ -7,6 +7,14 @@ + + + ..\..\..\..\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.7.2\System.Net.dll + + + ..\..\..\..\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.7.2\System.Web.dll + + From 487f138905165a78a78b87530e092c6a877f80e7 Mon Sep 17 00:00:00 2001 From: tuttb Date: Thu, 7 Sep 2023 01:00:17 -0400 Subject: [PATCH 09/24] Add new files.. --- .../Options/RestClientRedirectionOptions.cs | 36 +++++++++++++++++++ .../Handlers/RedirectWithStatusCodeResult.cs | 33 +++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 src/RestSharp/Options/RestClientRedirectionOptions.cs create mode 100644 test/RestSharp.Tests.Integrated/Server/Handlers/RedirectWithStatusCodeResult.cs diff --git a/src/RestSharp/Options/RestClientRedirectionOptions.cs b/src/RestSharp/Options/RestClientRedirectionOptions.cs new file mode 100644 index 000000000..95a4de72d --- /dev/null +++ b/src/RestSharp/Options/RestClientRedirectionOptions.cs @@ -0,0 +1,36 @@ +using RestSharp.Extensions; +using System; +using System.Collections.Generic; +using System.Net; +using System.Reflection; +using System.Text; + +namespace RestSharp.Options { + [GenerateImmutable] + public class RestClientRedirectionOptions { + static readonly Version Version = new AssemblyName(typeof(RestClientOptions).Assembly.FullName!).Version!; + + public bool FollowRedirects { get; set; } = true; + public bool FollowRedirectsToInsecure { get; set; } = false; + public bool ForwardHeaders { get; set; } = true; + public bool ForwardAuthorization { get; set; } = false; + public bool ForwardCookies { get; set; } = true; + public bool ForwardBody { get; set; } = true; + public bool ForwardQuery { get; set; } = true; + public int MaxRedirects { get; set; } + public bool ForwardFragment { get; set; } = true; + public IReadOnlyList RedirectStatusCodes { get; set; } + + public RestClientRedirectionOptions() { + RedirectStatusCodes = new List() { + HttpStatusCode.MovedPermanently, + HttpStatusCode.SeeOther, + HttpStatusCode.TemporaryRedirect, + HttpStatusCode.Redirect, + #if NET + HttpStatusCode.PermanentRedirect, + #endif + }.AsReadOnly(); + } + } +} diff --git a/test/RestSharp.Tests.Integrated/Server/Handlers/RedirectWithStatusCodeResult.cs b/test/RestSharp.Tests.Integrated/Server/Handlers/RedirectWithStatusCodeResult.cs new file mode 100644 index 000000000..d5cc79f3d --- /dev/null +++ b/test/RestSharp.Tests.Integrated/Server/Handlers/RedirectWithStatusCodeResult.cs @@ -0,0 +1,33 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Text; +using System.Threading.Tasks; + +namespace RestSharp.Tests.Integrated.Server.Handlers; + +/// +/// An that returns a redirection with a supplied status code value. +/// Created in order to easily return a SeeOther status code. +/// +class RedirectWithStatusCodeResult : IResult { + public int StatusCode { get; } + public string Uri { get; } + + public RedirectWithStatusCodeResult(int statusCode, string url) { + Uri = url; + StatusCode = statusCode; + } + + public Task ExecuteAsync(HttpContext httpContext) { + ArgumentNullException.ThrowIfNull(httpContext); + + httpContext.Response.StatusCode = StatusCode; + httpContext.Response.Headers.Location = Uri; + + return Task.CompletedTask; + } +} From a0baaa67217bbe62a71b42856117da6f76cd0b3c Mon Sep 17 00:00:00 2001 From: tuttb Date: Thu, 7 Sep 2023 01:31:02 -0400 Subject: [PATCH 10/24] FileParameter: Mark the obsoleted property as NOT CLSCompliant to reduce warnings.. RequestContent: Don't use obsoleted property. RestClientOptions*: use new RedirectOptions class instead for FollowRedirects. --- src/RestSharp/Options/RestClientOptions.cs | 16 +++++- .../Options/RestClientRedirectionOptions.cs | 54 +++++++++---------- src/RestSharp/Parameters/FileParameter.cs | 1 + src/RestSharp/Request/RequestContent.cs | 2 +- 4 files changed, 42 insertions(+), 31 deletions(-) diff --git a/src/RestSharp/Options/RestClientOptions.cs b/src/RestSharp/Options/RestClientOptions.cs index 95db15020..6363a51db 100644 --- a/src/RestSharp/Options/RestClientOptions.cs +++ b/src/RestSharp/Options/RestClientOptions.cs @@ -60,7 +60,7 @@ public RestClientOptions(string baseUrl) : this(new Uri(Ensure.NotEmptyString(ba ? ResponseStatus.Completed : ResponseStatus.Error; - /// + /// s /// Authenticator that will be used to populate request with necessary authentication data /// public IAuthenticator? Authenticator { get; set; } @@ -131,10 +131,22 @@ public RestClientOptions(string baseUrl) : this(new Uri(Ensure.NotEmptyString(ba /// public CacheControlHeaderValue? CachePolicy { get; set; } + /// + /// Policy settings for redirect processing + /// + public RestClientRedirectionOptions RedirectOptions { get; set; } = new RestClientRedirectionOptions(); + /// /// Instruct the client to follow redirects. Default is true. /// - public bool FollowRedirects { get; set; } = true; + public bool FollowRedirects { + get { + return RedirectOptions.FollowRedirects; + } + set { + RedirectOptions.FollowRedirects = value; + } + } /// /// Gets or sets a value that indicates if the header for an HTTP request contains Continue. diff --git a/src/RestSharp/Options/RestClientRedirectionOptions.cs b/src/RestSharp/Options/RestClientRedirectionOptions.cs index 95a4de72d..dab7cd789 100644 --- a/src/RestSharp/Options/RestClientRedirectionOptions.cs +++ b/src/RestSharp/Options/RestClientRedirectionOptions.cs @@ -1,36 +1,34 @@ using RestSharp.Extensions; -using System; -using System.Collections.Generic; using System.Net; using System.Reflection; -using System.Text; -namespace RestSharp.Options { - [GenerateImmutable] - public class RestClientRedirectionOptions { - static readonly Version Version = new AssemblyName(typeof(RestClientOptions).Assembly.FullName!).Version!; +namespace RestSharp; - public bool FollowRedirects { get; set; } = true; - public bool FollowRedirectsToInsecure { get; set; } = false; - public bool ForwardHeaders { get; set; } = true; - public bool ForwardAuthorization { get; set; } = false; - public bool ForwardCookies { get; set; } = true; - public bool ForwardBody { get; set; } = true; - public bool ForwardQuery { get; set; } = true; - public int MaxRedirects { get; set; } - public bool ForwardFragment { get; set; } = true; - public IReadOnlyList RedirectStatusCodes { get; set; } +[GenerateImmutable] +public class RestClientRedirectionOptions { + static readonly Version Version = new AssemblyName(typeof(RestClientOptions).Assembly.FullName!).Version!; - public RestClientRedirectionOptions() { - RedirectStatusCodes = new List() { - HttpStatusCode.MovedPermanently, - HttpStatusCode.SeeOther, - HttpStatusCode.TemporaryRedirect, - HttpStatusCode.Redirect, - #if NET - HttpStatusCode.PermanentRedirect, - #endif - }.AsReadOnly(); - } + public bool FollowRedirects { get; set; } = true; + public bool FollowRedirectsToInsecure { get; set; } = false; + public bool ForwardHeaders { get; set; } = true; + public bool ForwardAuthorization { get; set; } = false; + public bool ForwardCookies { get; set; } = true; + public bool ForwardBody { get; set; } = true; + public bool ForwardQuery { get; set; } = true; + public int MaxRedirects { get; set; } + public bool ForwardFragment { get; set; } = true; + public IReadOnlyList RedirectStatusCodes { get; set; } + + public RestClientRedirectionOptions() { + RedirectStatusCodes = new List() { + HttpStatusCode.MovedPermanently, + HttpStatusCode.SeeOther, + HttpStatusCode.TemporaryRedirect, + HttpStatusCode.Redirect, +#if NET + HttpStatusCode.PermanentRedirect, +#endif + }.AsReadOnly(); } } + diff --git a/src/RestSharp/Parameters/FileParameter.cs b/src/RestSharp/Parameters/FileParameter.cs index 5b58bf44d..30813087a 100644 --- a/src/RestSharp/Parameters/FileParameter.cs +++ b/src/RestSharp/Parameters/FileParameter.cs @@ -114,6 +114,7 @@ public static FileParameter FromFile( [PublicAPI] public class FileParameterOptions { [Obsolete("Use DisableFilenameStar instead")] + [CLSCompliant(false)] public bool DisableFileNameStar { get => DisableFilenameStar; set => DisableFilenameStar = value; diff --git a/src/RestSharp/Request/RequestContent.cs b/src/RestSharp/Request/RequestContent.cs index cfc7995ca..38d1ac90e 100644 --- a/src/RestSharp/Request/RequestContent.cs +++ b/src/RestSharp/Request/RequestContent.cs @@ -83,7 +83,7 @@ StreamContent ToStreamContent(FileParameter fileParameter) { var dispositionHeader = fileParameter.Options.DisableFilenameEncoding ? ContentDispositionHeaderValue.Parse($"form-data; name=\"{fileParameter.Name}\"; filename=\"{fileParameter.FileName}\"") : new ContentDispositionHeaderValue("form-data") { Name = $"\"{fileParameter.Name}\"", FileName = $"\"{fileParameter.FileName}\"" }; - if (!fileParameter.Options.DisableFileNameStar) dispositionHeader.FileNameStar = fileParameter.FileName; + if (!fileParameter.Options.DisableFilenameStar) dispositionHeader.FileNameStar = fileParameter.FileName; streamContent.Headers.ContentDisposition = dispositionHeader; return streamContent; From d3c78069f366a6e34d9c8f684deae1735a72de58 Mon Sep 17 00:00:00 2001 From: tuttb Date: Thu, 7 Sep 2023 01:35:51 -0400 Subject: [PATCH 11/24] Try to pull in the correct assemblies for System.Web.HttpUtility.ParseQueryString --- test/RestSharp.Tests/RestSharp.Tests.csproj | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/test/RestSharp.Tests/RestSharp.Tests.csproj b/test/RestSharp.Tests/RestSharp.Tests.csproj index 8f07f9ab3..37251b528 100644 --- a/test/RestSharp.Tests/RestSharp.Tests.csproj +++ b/test/RestSharp.Tests/RestSharp.Tests.csproj @@ -7,13 +7,10 @@ - - - ..\..\..\..\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.7.2\System.Net.dll - - - ..\..\..\..\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.7.2\System.Web.dll - + + + + From 28203df214a1a6f9c8856745d830d32a98c7cafa Mon Sep 17 00:00:00 2001 From: tuttb Date: Thu, 7 Sep 2023 02:37:58 -0400 Subject: [PATCH 12/24] Start paying attention to some of the Options.RedirectionOptions properties. --- src/RestSharp/KnownHeaders.cs | 1 + .../Options/RestClientRedirectionOptions.cs | 81 +++++++++++++++- src/RestSharp/RestClient.Async.cs | 97 +++++++++++++------ 3 files changed, 146 insertions(+), 33 deletions(-) diff --git a/src/RestSharp/KnownHeaders.cs b/src/RestSharp/KnownHeaders.cs index c8909a7be..e520937f0 100644 --- a/src/RestSharp/KnownHeaders.cs +++ b/src/RestSharp/KnownHeaders.cs @@ -36,6 +36,7 @@ public static class KnownHeaders { public const string Cookie = "Cookie"; public const string SetCookie = "Set-Cookie"; public const string UserAgent = "User-Agent"; + public const string TransferEncoding = "Transfer-Encoding"; internal static readonly string[] ContentHeaders = { Allow, Expires, ContentDisposition, ContentEncoding, ContentLanguage, ContentLength, ContentLocation, ContentRange, ContentType, ContentMD5, diff --git a/src/RestSharp/Options/RestClientRedirectionOptions.cs b/src/RestSharp/Options/RestClientRedirectionOptions.cs index dab7cd789..2b5b83e64 100644 --- a/src/RestSharp/Options/RestClientRedirectionOptions.cs +++ b/src/RestSharp/Options/RestClientRedirectionOptions.cs @@ -4,19 +4,98 @@ namespace RestSharp; +/// +/// Options related to redirect processing. +/// [GenerateImmutable] public class RestClientRedirectionOptions { static readonly Version Version = new AssemblyName(typeof(RestClientOptions).Assembly.FullName!).Version!; + /// + /// Set to true (default), when you want to follow redirects + /// public bool FollowRedirects { get; set; } = true; + + /// + /// Set to true (default is false), when you want to follow a + /// redirect from HTTPS to HTTP. + /// public bool FollowRedirectsToInsecure { get; set; } = false; + /// + /// Set to true (default), when you want to include the originally + /// requested headers in redirected requests. + /// public bool ForwardHeaders { get; set; } = true; + + /// + /// Set to true (default is false), when you want to send the original + /// Authorization header to the redirected destination. + /// public bool ForwardAuthorization { get; set; } = false; + /// + /// Set to true (default), when you want to include cookie3s from the + /// CookieContainer on the redirected URL. + /// + /// + /// NOTE: The exact cookies sent to the redirected url DEPENDS directly + /// on the redirected url. A redirection to a completly differnet FQDN + /// for example is unlikely to actually propagate any cookies from the + /// CookieContqainer. + /// public bool ForwardCookies { get; set; } = true; + + /// + /// Set to true (default) in order to send the body to the + /// redirected URL, unless the force verb to GET behavior is triggered. + /// + /// public bool ForwardBody { get; set; } = true; + + /// + /// Set to true (default is false) to force forwarding the body of the + /// request even when normally, the verb might be altered to GET based + /// on backward compatiblity with browser processing of HTTP status codes. + /// + /// + /// Based on Wikipedia https://en.wikipedia.org/wiki/HTTP_302: + ///
+    ///  Many web browsers implemented this code in a manner that violated this standard, changing
+    ///  the request type of the new request to GET, regardless of the type employed in the original request
+    ///  (e.g. POST). For this reason, HTTP/1.1 (RFC 2616) added the new status codes 303 and 307 to disambiguate
+    ///  between the two behaviours, with 303 mandating the change of request type to GET, and 307 preserving the
+    ///  request type as originally sent. Despite the greater clarity provided by this disambiguation, the 302 code
+    ///  is still employed in web frameworks to preserve compatibility with browsers that do not implement the HTTP/1.1
+    ///  specification.
+    /// 
+ ///
+ public bool ForceForwardBody { get; set; } = false; + + /// + /// Set to true (default) to forward the query string to the redirected URL. + /// public bool ForwardQuery { get; set; } = true; - public int MaxRedirects { get; set; } + + /// + /// The maximum number of redirects to follow. + /// + public int MaxRedirects { get; set; } = 10; + + /// + /// Set to true (default), to supply any requested fragment portion of the original URL to the destination URL. + /// + /// + /// Per https://tools.ietf.org/html/rfc7231#section-7.1.2, a redirect location without a + /// fragment should inherit the fragment from the original URI. + /// public bool ForwardFragment { get; set; } = true; + + /// + /// HttpStatusCodes that trigger redirect processing. Defaults to MovedPermanently (301), + /// SeeOther (303), + /// TemporaryRedirect (307), + /// Redirect (302), + /// PermanentRedirect (308) + /// public IReadOnlyList RedirectStatusCodes { get; set; } public RestClientRedirectionOptions() { diff --git a/src/RestSharp/RestClient.Async.cs b/src/RestSharp/RestClient.Async.cs index 6bae75086..af3e0d07f 100644 --- a/src/RestSharp/RestClient.Async.cs +++ b/src/RestSharp/RestClient.Async.cs @@ -13,6 +13,7 @@ // limitations under the License. using System.Net; +using System.Web; using RestSharp.Extensions; namespace RestSharp; @@ -141,8 +142,7 @@ async Task ExecuteRequestAsync(RestRequest request, CancellationTo if (request.OnAfterRequest != null) await request.OnAfterRequest(responseMessage).ConfigureAwait(false); - if (!IsRedirect(responseMessage)) { - // || !Options.FollowRedirects) { + if (!IsRedirect(Options.RedirectOptions, responseMessage)) { break; } @@ -159,26 +159,30 @@ async Task ExecuteRequestAsync(RestRequest request, CancellationTo // Mirror HttpClient redirection behavior as of 07/25/2023: // Per https://tools.ietf.org/html/rfc7231#section-7.1.2, a redirect location without a // fragment should inherit the fragment from the original URI. - string requestFragment = originalUrl.Fragment; - if (!string.IsNullOrEmpty(requestFragment)) { - string redirectFragment = location.Fragment; - if (string.IsNullOrEmpty(redirectFragment)) { - location = new UriBuilder(location) { Fragment = requestFragment }.Uri; + if (Options.RedirectOptions.ForwardFragment) { + string requestFragment = originalUrl.Fragment; + if (!string.IsNullOrEmpty(requestFragment)) { + string redirectFragment = location.Fragment; + if (string.IsNullOrEmpty(redirectFragment)) { + location = new UriBuilder(location) { Fragment = requestFragment }.Uri; + } } } // Disallow automatic redirection from secure to non-secure schemes - // From HttpClient's RedirectHandler: - //if (HttpUtilities.IsSupportedSecureScheme(requestUri.Scheme) && !HttpUtilities.IsSupportedSecureScheme(location.Scheme)) { - // if (NetEventSource.Log.IsEnabled()) { - // TraceError($"Insecure https to http redirect from '{requestUri}' to '{location}' blocked.", response.RequestMessage!.GetHashCode()); - // } - // break; - //} + // based on the option setting: + if (HttpUtilities.IsSupportedSecureScheme(requestUri.Scheme) + && !HttpUtilities.IsSupportedSecureScheme(location.Scheme) + && !Options.RedirectOptions.FollowRedirectsToInsecure) { + // TODO: Log here... + break; + } if (responseMessage.StatusCode == HttpStatusCode.RedirectMethod) { + // TODO: Add RedirectionOptions property for this decision: httpMethod = HttpMethod.Get; } + // Based on Wikipedia https://en.wikipedia.org/wiki/HTTP_302: // Many web browsers implemented this code in a manner that violated this standard, changing // the request type of the new request to GET, regardless of the type employed in the original request @@ -192,13 +196,20 @@ async Task ExecuteRequestAsync(RestRequest request, CancellationTo // solves this problem by a helper method: if (RedirectRequestRequiresForceGet(responseMessage.StatusCode, httpMethod)) { httpMethod = HttpMethod.Get; - // HttpClient sets request.Content to null here: - // TODO: However... should we be allowed to modify Request like that here? - message.Content = null; - // HttpClient Redirect handler also does this: - //if (message.Headers.TansferEncodingChunked == true) { - // request.Headers.TransferEncodingChunked = false; - //} + if (!Options.RedirectOptions.ForceForwardBody) { + // HttpClient RedirectHandler sets request.Content to null here: + message.Content = null; + // HttpClient Redirect handler also does this: + //if (message.Headers.TansferEncodingChunked == true) { + // request.Headers.TransferEncodingChunked = false; + //} + Parameter? transferEncoding = request.Parameters.TryFind(KnownHeaders.TransferEncoding); + if (transferEncoding != null + && transferEncoding.Type == ParameterType.HttpHeader + && string.Equals((string)transferEncoding.Value!, "chunked", StringComparison.OrdinalIgnoreCase)) { + message.Headers.Remove(KnownHeaders.TransferEncoding); + } + } } url = location; @@ -257,6 +268,35 @@ async Task OnAfterRequest(HttpResponseMessage responseMessage) { } } + /// + /// From https://github.com/dotnet/runtime/blob/main/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpUtilities.cs + /// + private static class HttpUtilities { + internal static bool IsSupportedScheme(string scheme) => + IsSupportedNonSecureScheme(scheme) || + IsSupportedSecureScheme(scheme); + + internal static bool IsSupportedNonSecureScheme(string scheme) => + string.Equals(scheme, "http", StringComparison.OrdinalIgnoreCase) || IsNonSecureWebSocketScheme(scheme); + + internal static bool IsSupportedSecureScheme(string scheme) => + string.Equals(scheme, "https", StringComparison.OrdinalIgnoreCase) || IsSecureWebSocketScheme(scheme); + + internal static bool IsNonSecureWebSocketScheme(string scheme) => + string.Equals(scheme, "ws", StringComparison.OrdinalIgnoreCase); + + internal static bool IsSecureWebSocketScheme(string scheme) => + string.Equals(scheme, "wss", StringComparison.OrdinalIgnoreCase); + + internal static bool IsSupportedProxyScheme(string scheme) => + string.Equals(scheme, "http", StringComparison.OrdinalIgnoreCase) || string.Equals(scheme, "https", StringComparison.OrdinalIgnoreCase) || IsSocksScheme(scheme); + + internal static bool IsSocksScheme(string scheme) => + string.Equals(scheme, "socks5", StringComparison.OrdinalIgnoreCase) || + string.Equals(scheme, "socks4a", StringComparison.OrdinalIgnoreCase) || + string.Equals(scheme, "socks4", StringComparison.OrdinalIgnoreCase); + } + /// /// Based on .net core RedirectHandler class: /// https://github.com/dotnet/runtime/blob/main/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/RedirectHandler.cs @@ -283,17 +323,10 @@ HttpRequestMessage PrepareRequestMessage(HttpMethod httpMethod, Uri url, HttpCon return message; } - static bool IsRedirect(HttpResponseMessage responseMessage) - => responseMessage.StatusCode switch { - HttpStatusCode.MovedPermanently => true, - HttpStatusCode.SeeOther => true, - HttpStatusCode.TemporaryRedirect => true, - HttpStatusCode.Redirect => true, -#if NET - HttpStatusCode.PermanentRedirect => true, -#endif - _ => false - }; + static bool IsRedirect(RestClientRedirectionOptions options, HttpResponseMessage responseMessage) + { + return options.RedirectStatusCodes.Contains(responseMessage.StatusCode); + } record HttpResponse( HttpResponseMessage? ResponseMessage, From 6557da7ad5610916af51bfb0e63015d0a8dda5f6 Mon Sep 17 00:00:00 2001 From: tuttb Date: Thu, 7 Sep 2023 02:40:46 -0400 Subject: [PATCH 13/24] Fix coding style violation.. --- src/RestSharp/RestClient.Async.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/RestSharp/RestClient.Async.cs b/src/RestSharp/RestClient.Async.cs index af3e0d07f..862fad3cb 100644 --- a/src/RestSharp/RestClient.Async.cs +++ b/src/RestSharp/RestClient.Async.cs @@ -323,8 +323,7 @@ HttpRequestMessage PrepareRequestMessage(HttpMethod httpMethod, Uri url, HttpCon return message; } - static bool IsRedirect(RestClientRedirectionOptions options, HttpResponseMessage responseMessage) - { + static bool IsRedirect(RestClientRedirectionOptions options, HttpResponseMessage responseMessage) { return options.RedirectStatusCodes.Contains(responseMessage.StatusCode); } From eaae0b100691d6cce6fa14df047ba7bb9038499f Mon Sep 17 00:00:00 2001 From: rassilon Date: Thu, 2 Nov 2023 15:38:08 -0400 Subject: [PATCH 14/24] Improvements (and corrections on some tiny merge errors during) based on rebasing... --- .../Options/RestClientRedirectionOptions.cs | 11 +++++- src/RestSharp/RestClient.Async.cs | 36 ++++++------------- .../Server/TestServer.cs | 1 - 3 files changed, 21 insertions(+), 27 deletions(-) diff --git a/src/RestSharp/Options/RestClientRedirectionOptions.cs b/src/RestSharp/Options/RestClientRedirectionOptions.cs index 2b5b83e64..8962b10d6 100644 --- a/src/RestSharp/Options/RestClientRedirectionOptions.cs +++ b/src/RestSharp/Options/RestClientRedirectionOptions.cs @@ -21,6 +21,7 @@ public class RestClientRedirectionOptions { /// redirect from HTTPS to HTTP. /// public bool FollowRedirectsToInsecure { get; set; } = false; + /// /// Set to true (default), when you want to include the originally /// requested headers in redirected requests. @@ -32,8 +33,9 @@ public class RestClientRedirectionOptions { /// Authorization header to the redirected destination. /// public bool ForwardAuthorization { get; set; } = false; + /// - /// Set to true (default), when you want to include cookie3s from the + /// Set to true (default), when you want to include cookies from the /// CookieContainer on the redirected URL. /// /// @@ -88,6 +90,13 @@ public class RestClientRedirectionOptions { /// fragment should inherit the fragment from the original URI. /// public bool ForwardFragment { get; set; } = true; + + /// + /// Set to true (default), to allow the HTTP Method used on the original request to + /// be replaced with GET when the status code 303 (HttpStatusCode.RedirectMethod) + /// was returned. Setting this to false will disallow the altering of the verb. + /// + public bool AllowRedirectMethodStatusCodeToAlterVerb { get; set; } = true; /// /// HttpStatusCodes that trigger redirect processing. Defaults to MovedPermanently (301), diff --git a/src/RestSharp/RestClient.Async.cs b/src/RestSharp/RestClient.Async.cs index 862fad3cb..b13037daf 100644 --- a/src/RestSharp/RestClient.Async.cs +++ b/src/RestSharp/RestClient.Async.cs @@ -94,26 +94,12 @@ async Task ExecuteRequestAsync(RestRequest request, CancellationTo using var cts = CancellationTokenSource.CreateLinkedTokenSource(timeoutCts.Token, cancellationToken); var ct = cts.Token; - - - HttpResponseMessage? responseMessage; - // Make sure we have a cookie container if not provided in the request - CookieContainer cookieContainer = request.CookieContainer ??= new CookieContainer(); - var headers = new RequestHeaders() - .AddHeaders(request.Parameters) - .AddHeaders(DefaultParameters) - .AddAcceptHeader(AcceptedContentTypes) - .AddCookieHeaders(url, cookieContainer) - .AddCookieHeaders(url, Options.CookieContainer); + HttpResponseMessage? responseMessage = null; + var cookieContainer = request.CookieContainer ??= new CookieContainer(); - message.AddHeaders(headers); - if (request.OnBeforeRequest != null) await request.OnBeforeRequest(message).ConfigureAwait(false); - await OnBeforeRequest(message).ConfigureAwait(false); - try { // Make sure we have a cookie container if not provided in the request - var cookieContainer = request.CookieContainer ??= new CookieContainer(); var headers = new RequestHeaders() .AddHeaders(request.Parameters) @@ -123,7 +109,6 @@ async Task ExecuteRequestAsync(RestRequest request, CancellationTo .AddCookieHeaders(url, Options.CookieContainer); bool foundCookies = false; - HttpResponseMessage? responseMessage = null; do { using var requestContent = new RequestContent(this, request); @@ -137,10 +122,12 @@ async Task ExecuteRequestAsync(RestRequest request, CancellationTo using var message = PrepareRequestMessage(httpMethod, url, content, headers); if (request.OnBeforeRequest != null) await request.OnBeforeRequest(message).ConfigureAwait(false); + await OnBeforeRequest(message).ConfigureAwait(false); responseMessage = await HttpClient.SendAsync(message, request.CompletionOption, ct).ConfigureAwait(false); if (request.OnAfterRequest != null) await request.OnAfterRequest(responseMessage).ConfigureAwait(false); + await OnAfterRequest(responseMessage).ConfigureAwait(false); if (!IsRedirect(Options.RedirectOptions, responseMessage)) { break; @@ -171,15 +158,17 @@ async Task ExecuteRequestAsync(RestRequest request, CancellationTo // Disallow automatic redirection from secure to non-secure schemes // based on the option setting: - if (HttpUtilities.IsSupportedSecureScheme(requestUri.Scheme) + if (HttpUtilities.IsSupportedSecureScheme(originalUrl.Scheme) && !HttpUtilities.IsSupportedSecureScheme(location.Scheme) && !Options.RedirectOptions.FollowRedirectsToInsecure) { // TODO: Log here... break; } - if (responseMessage.StatusCode == HttpStatusCode.RedirectMethod) { - // TODO: Add RedirectionOptions property for this decision: + // This is the expected behavior for this status code, but + // ignore it if requested from the RedirectOptions: + if (responseMessage.StatusCode == HttpStatusCode.RedirectMethod + && Options.RedirectOptions.AllowRedirectMethodStatusCodeToAlterVerb) { httpMethod = HttpMethod.Get; } @@ -199,10 +188,8 @@ async Task ExecuteRequestAsync(RestRequest request, CancellationTo if (!Options.RedirectOptions.ForceForwardBody) { // HttpClient RedirectHandler sets request.Content to null here: message.Content = null; - // HttpClient Redirect handler also does this: - //if (message.Headers.TansferEncodingChunked == true) { - // request.Headers.TransferEncodingChunked = false; - //} + // HttpClient Redirect handler also foribly removes + // a Transfer-Encoding of chunked in this case. Parameter? transferEncoding = request.Parameters.TryFind(KnownHeaders.TransferEncoding); if (transferEncoding != null && transferEncoding.Type == ParameterType.HttpHeader @@ -237,7 +224,6 @@ async Task ExecuteRequestAsync(RestRequest request, CancellationTo if (request.OnAfterRequest != null) await request.OnAfterRequest(responseMessage).ConfigureAwait(false); await OnAfterRequest(responseMessage).ConfigureAwait(false); return new HttpResponse(responseMessage, url, cookieContainer, null, timeoutCts.Token); - } /// diff --git a/test/RestSharp.Tests.Integrated/Server/TestServer.cs b/test/RestSharp.Tests.Integrated/Server/TestServer.cs index 0c32396b1..46988ba28 100644 --- a/test/RestSharp.Tests.Integrated/Server/TestServer.cs +++ b/test/RestSharp.Tests.Integrated/Server/TestServer.cs @@ -38,7 +38,6 @@ public HttpServer(ITestOutputHelper? output = null) { _app.MapGet("headers", HeaderHandlers.HandleHeaders); _app.MapGet("request-echo", async context => await context.Request.BodyReader.AsStream().CopyToAsync(context.Response.BodyWriter.AsStream())); _app.MapDelete("delete", () => new TestResponse { Message = "Works!" }); - _app.MapGet("redirect", () => Results.Redirect("/success", false, true)); // Cookies _app.MapGet("get-cookies", CookieHandlers.HandleCookies); From 9d8baf5940fb6c07d64110217a1e537e8e9c455c Mon Sep 17 00:00:00 2001 From: rassilon Date: Thu, 2 Nov 2023 17:54:24 -0400 Subject: [PATCH 15/24] Fixes for Interceptor tests. --- src/RestSharp/Options/RestClientOptions.cs | 9 ++++++ .../Options/RestClientRedirectionOptions.cs | 2 +- src/RestSharp/RestClient.Async.cs | 28 +++++++++++++------ src/RestSharp/RestClient.cs | 4 ++- src/RestSharp/RestClientInternalException.cs | 22 +++++++++++++++ .../RedirectTests.cs | 26 ++++++++++++++++- test/RestSharp.Tests/OptionsTests.cs | 4 +-- 7 files changed, 82 insertions(+), 13 deletions(-) create mode 100644 src/RestSharp/RestClientInternalException.cs diff --git a/src/RestSharp/Options/RestClientOptions.cs b/src/RestSharp/Options/RestClientOptions.cs index 6363a51db..9d70faabe 100644 --- a/src/RestSharp/Options/RestClientOptions.cs +++ b/src/RestSharp/Options/RestClientOptions.cs @@ -50,6 +50,12 @@ public RestClientOptions(string baseUrl) : this(new Uri(Ensure.NotEmptyString(ba /// /// Custom configuration for the underlying /// + /// + /// With the addition of all redirection processing being implemented directly by + /// please do not alter the from its default supplied by RestClient. + /// If you set to true, then redirection cookie + /// processing improvements in RestClient will be skipped since will hide the details from us. + /// public Func? ConfigureMessageHandler { get; set; } /// @@ -139,6 +145,9 @@ public RestClientOptions(string baseUrl) : this(new Uri(Ensure.NotEmptyString(ba /// /// Instruct the client to follow redirects. Default is true. /// + /// + /// Note: This now delegates the property implementation to . + /// public bool FollowRedirects { get { return RedirectOptions.FollowRedirects; diff --git a/src/RestSharp/Options/RestClientRedirectionOptions.cs b/src/RestSharp/Options/RestClientRedirectionOptions.cs index 8962b10d6..ae22e3638 100644 --- a/src/RestSharp/Options/RestClientRedirectionOptions.cs +++ b/src/RestSharp/Options/RestClientRedirectionOptions.cs @@ -100,7 +100,7 @@ public class RestClientRedirectionOptions { /// /// HttpStatusCodes that trigger redirect processing. Defaults to MovedPermanently (301), - /// SeeOther (303), + /// SeeOther/RedirectMethod (303), /// TemporaryRedirect (307), /// Redirect (302), /// PermanentRedirect (308) diff --git a/src/RestSharp/RestClient.Async.cs b/src/RestSharp/RestClient.Async.cs index b13037daf..c6ca28c26 100644 --- a/src/RestSharp/RestClient.Async.cs +++ b/src/RestSharp/RestClient.Async.cs @@ -96,11 +96,10 @@ async Task ExecuteRequestAsync(RestRequest request, CancellationTo var ct = cts.Token; HttpResponseMessage? responseMessage = null; + // Make sure we have a cookie container if not provided in the request var cookieContainer = request.CookieContainer ??= new CookieContainer(); try { - // Make sure we have a cookie container if not provided in the request - var headers = new RequestHeaders() .AddHeaders(request.Parameters) .AddHeaders(DefaultParameters) @@ -109,6 +108,7 @@ async Task ExecuteRequestAsync(RestRequest request, CancellationTo .AddCookieHeaders(url, Options.CookieContainer); bool foundCookies = false; + bool firstAttempt = true; do { using var requestContent = new RequestContent(this, request); @@ -121,14 +121,19 @@ async Task ExecuteRequestAsync(RestRequest request, CancellationTo } using var message = PrepareRequestMessage(httpMethod, url, content, headers); - if (request.OnBeforeRequest != null) await request.OnBeforeRequest(message).ConfigureAwait(false); - await OnBeforeRequest(message).ConfigureAwait(false); + if (firstAttempt) { + firstAttempt = false; + try { + if (request.OnBeforeRequest != null) await request.OnBeforeRequest(message).ConfigureAwait(false); + await OnBeforeRequest(message).ConfigureAwait(false); + } + catch (Exception e) { + throw new RestClientInternalException("RestClient.ExecuteRequestAsync OnBeforeRequest threw an exception: ", e); + } + } responseMessage = await HttpClient.SendAsync(message, request.CompletionOption, ct).ConfigureAwait(false); - if (request.OnAfterRequest != null) await request.OnAfterRequest(responseMessage).ConfigureAwait(false); - await OnAfterRequest(responseMessage).ConfigureAwait(false); - if (!IsRedirect(Options.RedirectOptions, responseMessage)) { break; } @@ -187,6 +192,10 @@ async Task ExecuteRequestAsync(RestRequest request, CancellationTo httpMethod = HttpMethod.Get; if (!Options.RedirectOptions.ForceForwardBody) { // HttpClient RedirectHandler sets request.Content to null here: + // TODO: I don't think is quite correct yet.. + // We don't necessarily want to modify the original request, but.. + // is there a way to clone it properly and then clear out what we don't + // care about? message.Content = null; // HttpClient Redirect handler also foribly removes // a Transfer-Encoding of chunked in this case. @@ -218,6 +227,9 @@ async Task ExecuteRequestAsync(RestRequest request, CancellationTo Options.CookieContainer?.AddCookies(url, cookiesHeader2); } } + catch (RestClientInternalException e) { + throw e.InnerException!; + } catch (Exception ex) { return new HttpResponse(null, url, null, ex, timeoutCts.Token); } @@ -310,7 +322,7 @@ HttpRequestMessage PrepareRequestMessage(HttpMethod httpMethod, Uri url, HttpCon } static bool IsRedirect(RestClientRedirectionOptions options, HttpResponseMessage responseMessage) { - return options.RedirectStatusCodes.Contains(responseMessage.StatusCode); + return options.FollowRedirects && options.RedirectStatusCodes.Contains(responseMessage.StatusCode); } record HttpResponse( diff --git a/src/RestSharp/RestClient.cs b/src/RestSharp/RestClient.cs index ccaae9f21..874bd088a 100644 --- a/src/RestSharp/RestClient.cs +++ b/src/RestSharp/RestClient.cs @@ -247,7 +247,9 @@ static void ConfigureHttpMessageHandler(HttpClientHandler handler, ReadOnlyRestC #if NET } #endif - handler.AllowAutoRedirect = options.FollowRedirects; + // ExecuteAsync and RedirectionOptions now own + // redirection processing: + handler.AllowAutoRedirect = false; #if NET if (!OperatingSystem.IsBrowser() && !OperatingSystem.IsIOS() && !OperatingSystem.IsTvOS()) { diff --git a/src/RestSharp/RestClientInternalException.cs b/src/RestSharp/RestClientInternalException.cs new file mode 100644 index 000000000..70a5aedee --- /dev/null +++ b/src/RestSharp/RestClientInternalException.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Runtime.Serialization; +using System.Text; + +namespace RestSharp; + +/// +/// This exception SHOULD only be used for catching and throwing internal +/// exceptions within RestSharp. +/// +[Serializable] +public class RestClientInternalException : Exception { + public RestClientInternalException() { + } + + public RestClientInternalException(string? message, Exception? innerException) : base(message, innerException) { + } + + protected RestClientInternalException(SerializationInfo info, StreamingContext context) : base(info, context) { + } +} diff --git a/test/RestSharp.Tests.Integrated/RedirectTests.cs b/test/RestSharp.Tests.Integrated/RedirectTests.cs index 1620f6024..00567c806 100644 --- a/test/RestSharp.Tests.Integrated/RedirectTests.cs +++ b/test/RestSharp.Tests.Integrated/RedirectTests.cs @@ -25,7 +25,7 @@ public class RedirectTests { public RedirectTests(TestServerFixture fixture) { var options = new RestClientOptions(fixture.Server.Url) { - FollowRedirects = false + FollowRedirects = true }; _client = new RestClient(options); _host = _client.Options.BaseUrl!.Host; @@ -99,6 +99,30 @@ public async Task Can_Perform_PUT_Async_With_RedirectionResponse_Cookies() { response.StatusCode.Should().Be(HttpStatusCode.MethodNotAllowed); } + // Needed tests: + //Test: ForwardHeaders = false + //Test: ForwardHeaders = true (default) might not need separate test + //Test: ForwardAuthorization = true + //Test: ForwardAuthorization = false (default) probably need separate test + //Test: ForwardCookies = true (might already have test) + //Test: ForwardCookies = false + //Test: ForwardBody = true (default, might not need test) + //Test: ForwardBody = false + //Test: ForceForwardBody = false (default, might not need test) + //Test: ForwardQuery = true (default, might not need test) + //Test: ForwardQuery = false + //Test: MaxRedirects + //Test: ForwardFragment = true + //Test: ForwardFragment = false + //Test: AllowRedirectMethodStatusCodeToAlterVerb = true (default, might not need test) + //Test: AllowRedirectMethodStatusCodeToAlterVerb = false + //Test: Altered Redirect Status Codes list + //Test: FollowRedirects = false + // Problem: Need secure test server: + //Test: FollowRedirectsToInsecure = true + //Test: FollowRedirectsToInsecure = false + + class Response { public string? Message { get; set; } } diff --git a/test/RestSharp.Tests/OptionsTests.cs b/test/RestSharp.Tests/OptionsTests.cs index e1270a792..d6410345f 100644 --- a/test/RestSharp.Tests/OptionsTests.cs +++ b/test/RestSharp.Tests/OptionsTests.cs @@ -2,11 +2,11 @@ namespace RestSharp.Tests; public class OptionsTests { [Fact] - public void Ensure_follow_redirect() { + public void Ensure_no_httpclient_follow_redirect() { var value = false; var options = new RestClientOptions { FollowRedirects = true, ConfigureMessageHandler = Configure}; var _ = new RestClient(options); - value.Should().BeTrue(); + value.Should().BeFalse(); HttpMessageHandler Configure(HttpMessageHandler handler) { value = (handler as HttpClientHandler)!.AllowAutoRedirect; From 6764396d378134edac03c10fe62346f4845b7e82 Mon Sep 17 00:00:00 2001 From: rassilon Date: Mon, 6 Nov 2023 15:03:16 -0500 Subject: [PATCH 16/24] Start the process of testing the new RedirectOptions.. --- .../Options/RestClientRedirectionOptions.cs | 4 +- src/RestSharp/RestClient.Async.cs | 23 +++++--- .../RedirectOptionsTest.cs | 52 +++++++++++++++++++ .../RedirectTests.cs | 27 +++++++++- .../Server/TestServer.cs | 20 ++++++- 5 files changed, 115 insertions(+), 11 deletions(-) create mode 100644 test/RestSharp.Tests.Integrated/RedirectOptionsTest.cs diff --git a/src/RestSharp/Options/RestClientRedirectionOptions.cs b/src/RestSharp/Options/RestClientRedirectionOptions.cs index ae22e3638..a456a4538 100644 --- a/src/RestSharp/Options/RestClientRedirectionOptions.cs +++ b/src/RestSharp/Options/RestClientRedirectionOptions.cs @@ -36,13 +36,13 @@ public class RestClientRedirectionOptions { /// /// Set to true (default), when you want to include cookies from the - /// CookieContainer on the redirected URL. + /// on the redirected URL. /// /// /// NOTE: The exact cookies sent to the redirected url DEPENDS directly /// on the redirected url. A redirection to a completly differnet FQDN /// for example is unlikely to actually propagate any cookies from the - /// CookieContqainer. + /// . /// public bool ForwardCookies { get; set; } = true; diff --git a/src/RestSharp/RestClient.Async.cs b/src/RestSharp/RestClient.Async.cs index c6ca28c26..222bcf172 100644 --- a/src/RestSharp/RestClient.Async.cs +++ b/src/RestSharp/RestClient.Async.cs @@ -210,13 +210,24 @@ async Task ExecuteRequestAsync(RestRequest request, CancellationTo url = location; - if (responseMessage.Headers.TryGetValues(KnownHeaders.SetCookie, out var cookiesHeader1)) { - foundCookies = true; - // ReSharper disable once PossibleMultipleEnumeration - cookieContainer.AddCookies(url, cookiesHeader1); - // ReSharper disable once PossibleMultipleEnumeration - Options.CookieContainer?.AddCookies(url, cookiesHeader1); + if (Options.RedirectOptions.ForwardHeaders) { + if (!Options.RedirectOptions.ForwardAuthorization) { + headers.Parameters.RemoveParameter("Authorization"); + } + if (!Options.RedirectOptions.ForwardCookies) { + headers.Parameters.RemoveParameter("Cookie"); + } + else { + if (responseMessage.Headers.TryGetValues(KnownHeaders.SetCookie, out var cookiesHeader1)) { + foundCookies = true; + // ReSharper disable once PossibleMultipleEnumeration + cookieContainer.AddCookies(url, cookiesHeader1); + // ReSharper disable once PossibleMultipleEnumeration + Options.CookieContainer?.AddCookies(url, cookiesHeader1); + } + } } + } while (true); // Parse all the cookies from the response and update the cookie jar with cookies diff --git a/test/RestSharp.Tests.Integrated/RedirectOptionsTest.cs b/test/RestSharp.Tests.Integrated/RedirectOptionsTest.cs new file mode 100644 index 000000000..35cd1828e --- /dev/null +++ b/test/RestSharp.Tests.Integrated/RedirectOptionsTest.cs @@ -0,0 +1,52 @@ +using RestSharp.Tests.Integrated.Server; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace RestSharp.Tests.Integrated { + [Collection(nameof(TestServerCollection))] + public class RedirectOptionsTest { + readonly string _host; + readonly Uri _baseUri; + + public RedirectOptionsTest(TestServerFixture fixture) { + _baseUri = fixture.Server.Url; + _host = _baseUri.Host; + } + + RestClientOptions NewOptions() { + return new RestClientOptions(_baseUri); + } + + [Fact] + public async Task Can_RedirectForwardHeadersFalse_DropHeaders() { + var options = NewOptions(); + options.RedirectOptions.ForwardHeaders = false; + var client = new RestClient(options); + + // This request sets cookies and redirects to url param value + // if supplied, otherwise redirects to /get-cookies + var request = new RestRequest("/get-cookies-redirect") { + Method = Method.Get, + }; + request.AddQueryParameter("url", "/dump-headers"); + + var response = await client.ExecuteAsync(request); + response.ResponseUri.Should().Be($"{_baseUri}dump-headers"); + var content = response.Content; + content.Should().NotContain("'Accept':"); + content.Should().NotContain("'Host': "); + content.Should().NotContain("'User-Agent':"); + content.Should().NotContain("'Accept-Encoding':"); + content.Should().NotContain("'Cookie':"); + + // Verify the cookie exists from the redirected get: + response.Cookies.Count.Should().BeGreaterThan(0).And.Be(1); + response.Cookies[0].Name.Should().Be("redirectCookie"); + response.Cookies[0].Value.Should().Be("value1"); + } + + } +} diff --git a/test/RestSharp.Tests.Integrated/RedirectTests.cs b/test/RestSharp.Tests.Integrated/RedirectTests.cs index 00567c806..ced7fe632 100644 --- a/test/RestSharp.Tests.Integrated/RedirectTests.cs +++ b/test/RestSharp.Tests.Integrated/RedirectTests.cs @@ -14,6 +14,7 @@ // using System.Net; +using RestSharp.Extensions; using RestSharp.Tests.Integrated.Server; namespace RestSharp.Tests.Integrated; @@ -99,6 +100,30 @@ public async Task Can_Perform_PUT_Async_With_RedirectionResponse_Cookies() { response.StatusCode.Should().Be(HttpStatusCode.MethodNotAllowed); } + [Fact] + public async Task Can_ForwardHeadersTrue_OnRedirect() { + // This request sets cookies and redirects to url param value + // if supplied, otherwise redirects to /get-cookies + var request = new RestRequest("/get-cookies-redirect") { + Method = Method.Get, + }; + request.AddQueryParameter("url", "/dump-headers"); + + var response = await _client.ExecuteAsync(request); + response.ResponseUri.Should().Be($"{_client.Options.BaseUrl}dump-headers"); + var content = response.Content; + content.Should().Contain("'Accept':"); + content.Should().Contain($"'Host': {_client.Options.BaseHost}"); + content.Should().Contain("'User-Agent':"); + content.Should().Contain("'Accept-Encoding':"); + content.Should().Contain("'Cookie':"); + + // Verify the cookie exists from the redirected get: + response.Cookies.Count.Should().BeGreaterThan(0).And.Be(1); + response.Cookies[0].Name.Should().Be("redirectCookie"); + response.Cookies[0].Value.Should().Be("value1"); + } + // Needed tests: //Test: ForwardHeaders = false //Test: ForwardHeaders = true (default) might not need separate test @@ -112,7 +137,7 @@ public async Task Can_Perform_PUT_Async_With_RedirectionResponse_Cookies() { //Test: ForwardQuery = true (default, might not need test) //Test: ForwardQuery = false //Test: MaxRedirects - //Test: ForwardFragment = true + //Test: ForwardFragment = true (default) //Test: ForwardFragment = false //Test: AllowRedirectMethodStatusCodeToAlterVerb = true (default, might not need test) //Test: AllowRedirectMethodStatusCodeToAlterVerb = false diff --git a/test/RestSharp.Tests.Integrated/Server/TestServer.cs b/test/RestSharp.Tests.Integrated/Server/TestServer.cs index 46988ba28..f5d38dc81 100644 --- a/test/RestSharp.Tests.Integrated/Server/TestServer.cs +++ b/test/RestSharp.Tests.Integrated/Server/TestServer.cs @@ -6,6 +6,8 @@ using RestSharp.Tests.Integrated.Server.Handlers; using RestSharp.Tests.Shared.Extensions; using System.Net; +using System.Text; +using System.Web; // ReSharper disable ConvertClosureToMethodGroup @@ -38,6 +40,15 @@ public HttpServer(ITestOutputHelper? output = null) { _app.MapGet("headers", HeaderHandlers.HandleHeaders); _app.MapGet("request-echo", async context => await context.Request.BodyReader.AsStream().CopyToAsync(context.Response.BodyWriter.AsStream())); _app.MapDelete("delete", () => new TestResponse { Message = "Works!" }); + _app.MapGet("dump-headers", + (HttpContext ctx) => { + var headers = ctx.Request.Headers; + StringBuilder sb = new StringBuilder(); + foreach (var kvp in headers) { + sb.Append($"'{kvp.Key}': '{kvp.Value}',"); + } + return new TestResponse { Message = sb.ToString() }; + }); // Cookies _app.MapGet("get-cookies", CookieHandlers.HandleCookies); @@ -48,12 +59,17 @@ public HttpServer(ITestOutputHelper? output = null) { }); _app.MapGet("set-cookies", CookieHandlers.HandleSetCookies); _app.MapGet("redirect", () => Results.Redirect("/success", false, true)); - _app.MapGet( "get-cookies-redirect", (HttpContext ctx) => { ctx.Response.Cookies.Append("redirectCookie", "value1"); - return Results.Redirect("/get-cookies", false, true); + string redirectDestination = "/get-cookies"; + var queryString = HttpUtility.ParseQueryString(ctx.Request.QueryString.Value); + var urlParameter = queryString.Get("url"); + if (!string.IsNullOrEmpty(urlParameter)) { + redirectDestination = urlParameter; + } + return Results.Redirect(redirectDestination, false, true); } ); From a69b48edbbe09208448d0f38b45163b7ebdb0d1f Mon Sep 17 00:00:00 2001 From: rassilon Date: Tue, 27 Feb 2024 22:40:08 -0500 Subject: [PATCH 17/24] Update xunit adapter and visual studio test SDK nuget pkg versions for newer VS versions. --- .../RestSharp.InteractiveTests.csproj | 12 ++++++++++-- .../RestSharp.Tests.Integrated.csproj | 3 +++ .../RestSharp.Tests.Serializers.Csv.csproj | 9 +++++++-- .../RestSharp.Tests.Serializers.Json.csproj | 5 +++++ .../RestSharp.Tests.Serializers.Xml.csproj | 5 +++++ .../RestSharp.Tests.Shared.csproj | 3 +++ test/RestSharp.Tests/RestSharp.Tests.csproj | 10 +++++++++- 7 files changed, 42 insertions(+), 5 deletions(-) diff --git a/test/RestSharp.InteractiveTests/RestSharp.InteractiveTests.csproj b/test/RestSharp.InteractiveTests/RestSharp.InteractiveTests.csproj index c25f980cf..d86d135bf 100644 --- a/test/RestSharp.InteractiveTests/RestSharp.InteractiveTests.csproj +++ b/test/RestSharp.InteractiveTests/RestSharp.InteractiveTests.csproj @@ -1,11 +1,19 @@ - + Exe false net6 - + + + + + + + + + diff --git a/test/RestSharp.Tests.Integrated/RestSharp.Tests.Integrated.csproj b/test/RestSharp.Tests.Integrated/RestSharp.Tests.Integrated.csproj index cd511eb9b..a4058ba25 100644 --- a/test/RestSharp.Tests.Integrated/RestSharp.Tests.Integrated.csproj +++ b/test/RestSharp.Tests.Integrated/RestSharp.Tests.Integrated.csproj @@ -20,6 +20,9 @@ + + + diff --git a/test/RestSharp.Tests.Serializers.Csv/RestSharp.Tests.Serializers.Csv.csproj b/test/RestSharp.Tests.Serializers.Csv/RestSharp.Tests.Serializers.Csv.csproj index 4477eea34..ebe55a837 100644 --- a/test/RestSharp.Tests.Serializers.Csv/RestSharp.Tests.Serializers.Csv.csproj +++ b/test/RestSharp.Tests.Serializers.Csv/RestSharp.Tests.Serializers.Csv.csproj @@ -1,6 +1,11 @@  - - + + + + + + + diff --git a/test/RestSharp.Tests.Serializers.Json/RestSharp.Tests.Serializers.Json.csproj b/test/RestSharp.Tests.Serializers.Json/RestSharp.Tests.Serializers.Json.csproj index a7703166c..eac6a444d 100644 --- a/test/RestSharp.Tests.Serializers.Json/RestSharp.Tests.Serializers.Json.csproj +++ b/test/RestSharp.Tests.Serializers.Json/RestSharp.Tests.Serializers.Json.csproj @@ -15,4 +15,9 @@ + + + + + diff --git a/test/RestSharp.Tests.Serializers.Xml/RestSharp.Tests.Serializers.Xml.csproj b/test/RestSharp.Tests.Serializers.Xml/RestSharp.Tests.Serializers.Xml.csproj index 10c42ccc4..5a6518bd9 100644 --- a/test/RestSharp.Tests.Serializers.Xml/RestSharp.Tests.Serializers.Xml.csproj +++ b/test/RestSharp.Tests.Serializers.Xml/RestSharp.Tests.Serializers.Xml.csproj @@ -23,5 +23,10 @@ + + + + + diff --git a/test/RestSharp.Tests.Shared/RestSharp.Tests.Shared.csproj b/test/RestSharp.Tests.Shared/RestSharp.Tests.Shared.csproj index 274f12fb9..e15de7026 100644 --- a/test/RestSharp.Tests.Shared/RestSharp.Tests.Shared.csproj +++ b/test/RestSharp.Tests.Shared/RestSharp.Tests.Shared.csproj @@ -2,4 +2,7 @@ false + + + diff --git a/test/RestSharp.Tests/RestSharp.Tests.csproj b/test/RestSharp.Tests/RestSharp.Tests.csproj index 37251b528..929f18c26 100644 --- a/test/RestSharp.Tests/RestSharp.Tests.csproj +++ b/test/RestSharp.Tests/RestSharp.Tests.csproj @@ -4,10 +4,13 @@ + + true + - + @@ -32,4 +35,9 @@ + + + + + \ No newline at end of file From 88d141b72e679dd96096a1b6fa7c1442741dde51 Mon Sep 17 00:00:00 2001 From: tuttb Date: Mon, 11 Mar 2024 19:58:10 -0400 Subject: [PATCH 18/24] ForwardHeaders/ForwardAuthorization/ForwardCookies tests.. --- src/RestSharp/Options/RestClientOptions.cs | 6 +- .../Options/RestClientRedirectionOptions.cs | 11 +- src/RestSharp/RestClient.Async.cs | 58 ++++++--- .../RedirectOptionsTest.cs | 123 +++++++++++++++++- .../RedirectTests.cs | 6 - 5 files changed, 172 insertions(+), 32 deletions(-) diff --git a/src/RestSharp/Options/RestClientOptions.cs b/src/RestSharp/Options/RestClientOptions.cs index 9d70faabe..4d0bdf2bc 100644 --- a/src/RestSharp/Options/RestClientOptions.cs +++ b/src/RestSharp/Options/RestClientOptions.cs @@ -52,8 +52,8 @@ public RestClientOptions(string baseUrl) : this(new Uri(Ensure.NotEmptyString(ba /// /// /// With the addition of all redirection processing being implemented directly by - /// please do not alter the from its default supplied by RestClient. - /// If you set to true, then redirection cookie + /// please do not alter the from its default supplied by RestClient. + /// If you set to true, then redirection cookie /// processing improvements in RestClient will be skipped since will hide the details from us. /// public Func? ConfigureMessageHandler { get; set; } @@ -92,7 +92,7 @@ public RestClientOptions(string baseUrl) : this(new Uri(Ensure.NotEmptyString(ba public bool UseDefaultCredentials { get; set; } /// - /// Set to true if you need the Content-Type not to have the charset + /// Set to true if you need the Content-Type not to have the charset /// public bool DisableCharset { get; set; } diff --git a/src/RestSharp/Options/RestClientRedirectionOptions.cs b/src/RestSharp/Options/RestClientRedirectionOptions.cs index a456a4538..f7908a3e6 100644 --- a/src/RestSharp/Options/RestClientRedirectionOptions.cs +++ b/src/RestSharp/Options/RestClientRedirectionOptions.cs @@ -26,6 +26,9 @@ public class RestClientRedirectionOptions { /// Set to true (default), when you want to include the originally /// requested headers in redirected requests. /// + /// NOTE: The 'Authorization' header is controlled by , + /// and the 'Cookie' header is controlled by . + /// public bool ForwardHeaders { get; set; } = true; /// @@ -35,13 +38,13 @@ public class RestClientRedirectionOptions { public bool ForwardAuthorization { get; set; } = false; /// - /// Set to true (default), when you want to include cookies from the + /// Set to true (default), when you want to include cookies from the /// on the redirected URL. /// /// /// NOTE: The exact cookies sent to the redirected url DEPENDS directly /// on the redirected url. A redirection to a completly differnet FQDN - /// for example is unlikely to actually propagate any cookies from the + /// for example is unlikely to actually propagate any cookies from the /// . /// public bool ForwardCookies { get; set; } = true; @@ -54,7 +57,7 @@ public class RestClientRedirectionOptions { public bool ForwardBody { get; set; } = true; /// - /// Set to true (default is false) to force forwarding the body of the + /// Set to true (default is false) to force forwarding the body of the /// request even when normally, the verb might be altered to GET based /// on backward compatiblity with browser processing of HTTP status codes. /// @@ -97,7 +100,7 @@ public class RestClientRedirectionOptions { /// was returned. Setting this to false will disallow the altering of the verb. /// public bool AllowRedirectMethodStatusCodeToAlterVerb { get; set; } = true; - + /// /// HttpStatusCodes that trigger redirect processing. Defaults to MovedPermanently (301), /// SeeOther/RedirectMethod (303), diff --git a/src/RestSharp/RestClient.Async.cs b/src/RestSharp/RestClient.Async.cs index 222bcf172..cc8e9da3f 100644 --- a/src/RestSharp/RestClient.Async.cs +++ b/src/RestSharp/RestClient.Async.cs @@ -78,7 +78,7 @@ async Task ExecuteRequestAsync(RestRequest request, CancellationTo throw new ObjectDisposedException(nameof(RestClient)); } - await OnBeforeSerialization(request).ConfigureAwait(false); + await OnBeforeSerialization(request).ConfigureAwait(false); request.ValidateParameters(); var authenticator = request.Authenticator ?? Options.Authenticator; @@ -94,7 +94,7 @@ async Task ExecuteRequestAsync(RestRequest request, CancellationTo using var cts = CancellationTokenSource.CreateLinkedTokenSource(timeoutCts.Token, cancellationToken); var ct = cts.Token; - + HttpResponseMessage? responseMessage = null; // Make sure we have a cookie container if not provided in the request var cookieContainer = request.CookieContainer ??= new CookieContainer(); @@ -163,7 +163,7 @@ async Task ExecuteRequestAsync(RestRequest request, CancellationTo // Disallow automatic redirection from secure to non-secure schemes // based on the option setting: - if (HttpUtilities.IsSupportedSecureScheme(originalUrl.Scheme) + if (HttpUtilities.IsSupportedSecureScheme(originalUrl.Scheme) && !HttpUtilities.IsSupportedSecureScheme(location.Scheme) && !Options.RedirectOptions.FollowRedirectsToInsecure) { // TODO: Log here... @@ -192,7 +192,7 @@ async Task ExecuteRequestAsync(RestRequest request, CancellationTo httpMethod = HttpMethod.Get; if (!Options.RedirectOptions.ForceForwardBody) { // HttpClient RedirectHandler sets request.Content to null here: - // TODO: I don't think is quite correct yet.. + // TODO: I don't think is quite correct yet.. // We don't necessarily want to modify the original request, but.. // is there a way to clone it properly and then clear out what we don't // care about? @@ -210,24 +210,50 @@ async Task ExecuteRequestAsync(RestRequest request, CancellationTo url = location; + // Regardless of whether or not we will be forwarding + // cookies, the CookieContainer will be updated: + if (responseMessage.Headers.TryGetValues(KnownHeaders.SetCookie, out var cookiesHeader1)) { + if (Options.RedirectOptions.ForwardCookies) { + foundCookies = true; + } + // ReSharper disable once PossibleMultipleEnumeration + cookieContainer.AddCookies(url, cookiesHeader1); + // ReSharper disable once PossibleMultipleEnumeration + Options.CookieContainer?.AddCookies(url, cookiesHeader1); + } + + // Process header related RedirectOptions: if (Options.RedirectOptions.ForwardHeaders) { if (!Options.RedirectOptions.ForwardAuthorization) { - headers.Parameters.RemoveParameter("Authorization"); + headers.Parameters.RemoveParameter(KnownHeaders.Authorization); } if (!Options.RedirectOptions.ForwardCookies) { - headers.Parameters.RemoveParameter("Cookie"); + headers.Parameters.RemoveParameter(KnownHeaders.Cookie); } - else { - if (responseMessage.Headers.TryGetValues(KnownHeaders.SetCookie, out var cookiesHeader1)) { - foundCookies = true; - // ReSharper disable once PossibleMultipleEnumeration - cookieContainer.AddCookies(url, cookiesHeader1); - // ReSharper disable once PossibleMultipleEnumeration - Options.CookieContainer?.AddCookies(url, cookiesHeader1); + } + else { + List headersToRemove = new List(); + foreach (var param in headers.Parameters) { + if (param is HeaderParameter header) { + // Keep headers requested to be forwarded: + if (string.Compare(param.Name, KnownHeaders.Authorization, StringComparison.InvariantCultureIgnoreCase) == 0 + && Options.RedirectOptions.ForwardAuthorization) { + continue; + } + if (string.Compare(param.Name, KnownHeaders.Cookie, StringComparison.InvariantCultureIgnoreCase) == 0 + && Options.RedirectOptions.ForwardCookies) { + continue; + } + // Otherwise: schedule the items for removal: + headersToRemove.Add(param.Name); + } + } + if (headersToRemove.Count > 0) { + for (int i = 0; i < headersToRemove.Count; i++) { + headers.Parameters.RemoveParameter(headersToRemove[i]); } } } - } while (true); // Parse all the cookies from the response and update the cookie jar with cookies @@ -307,7 +333,7 @@ internal static bool IsSocksScheme(string scheme) => } /// - /// Based on .net core RedirectHandler class: + /// Based on .net core RedirectHandler class: /// https://github.com/dotnet/runtime/blob/main/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/RedirectHandler.cs /// /// @@ -316,7 +342,7 @@ internal static bool IsSocksScheme(string scheme) => /// private bool RedirectRequestRequiresForceGet(HttpStatusCode statusCode, HttpMethod httpMethod) { return statusCode switch { - HttpStatusCode.Moved or HttpStatusCode.Found or HttpStatusCode.MultipleChoices + HttpStatusCode.Moved or HttpStatusCode.Found or HttpStatusCode.MultipleChoices => httpMethod == HttpMethod.Post, HttpStatusCode.SeeOther => httpMethod != HttpMethod.Get && httpMethod != HttpMethod.Head, _ => false, diff --git a/test/RestSharp.Tests.Integrated/RedirectOptionsTest.cs b/test/RestSharp.Tests.Integrated/RedirectOptionsTest.cs index 35cd1828e..a831e2594 100644 --- a/test/RestSharp.Tests.Integrated/RedirectOptionsTest.cs +++ b/test/RestSharp.Tests.Integrated/RedirectOptionsTest.cs @@ -21,9 +21,10 @@ RestClientOptions NewOptions() { } [Fact] - public async Task Can_RedirectForwardHeadersFalse_DropHeaders() { + public async Task Can_RedirectForwardHeadersFalseWithAuthAndCookie_DropHeaders() { var options = NewOptions(); options.RedirectOptions.ForwardHeaders = false; + options.RedirectOptions.ForwardAuthorization = true; var client = new RestClient(options); // This request sets cookies and redirects to url param value @@ -31,16 +32,97 @@ public async Task Can_RedirectForwardHeadersFalse_DropHeaders() { var request = new RestRequest("/get-cookies-redirect") { Method = Method.Get, }; + request.AddHeader("Authorization", "blah"); request.AddQueryParameter("url", "/dump-headers"); var response = await client.ExecuteAsync(request); response.ResponseUri.Should().Be($"{_baseUri}dump-headers"); var content = response.Content; content.Should().NotContain("'Accept':"); - content.Should().NotContain("'Host': "); content.Should().NotContain("'User-Agent':"); - content.Should().NotContain("'Accept-Encoding':"); + // NOTE: This is expected to be there for normal HTTP purposes + // and is expected to be re-added by the underlying HttpClient: + content.Should().Contain("'Host': "); + // NOTE: options.AutomaticDecompression controls + // Accept-Encoding, so since we did nothing to change that + // the underlying HttpClient will re-add this header: + content.Should().Contain("'Accept-Encoding':"); + // These are expected due to redirection options for this test: + content.Should().Contain("'Cookie':"); + content.Should().Contain("'Authorization':"); + + // Verify the cookie exists from the redirected get: + response.Cookies.Count.Should().BeGreaterThan(0).And.Be(1); + response.Cookies[0].Name.Should().Be("redirectCookie"); + response.Cookies[0].Value.Should().Be("value1"); + } + + [Fact] + public async Task Can_RedirectForwardHeadersFalseWithCookie_DropHeaders() { + var options = NewOptions(); + options.RedirectOptions.ForwardHeaders = false; + var client = new RestClient(options); + + // This request sets cookies and redirects to url param value + // if supplied, otherwise redirects to /get-cookies + var request = new RestRequest("/get-cookies-redirect") { + Method = Method.Get, + }; + request.AddHeader("Authorization", "blah"); + request.AddQueryParameter("url", "/dump-headers"); + + var response = await client.ExecuteAsync(request); + response.ResponseUri.Should().Be($"{_baseUri}dump-headers"); + var content = response.Content; + content.Should().NotContain("'Accept':"); + content.Should().NotContain("'User-Agent':"); + content.Should().NotContain("'Authorization':"); + // NOTE: This is expected to be there for normal HTTP purposes + // and is expected to be re-added by the underlying HttpClient: + content.Should().Contain("'Host': "); + // NOTE: options.AutomaticDecompression controls + // Accept-Encoding, so since we did nothing to change that + // the underlying HttpClient will re-add this header: + content.Should().Contain("'Accept-Encoding':"); + // These are expected due to redirection options for this test: + content.Should().Contain("'Cookie':"); + + // Verify the cookie exists from the redirected get: + response.Cookies.Count.Should().BeGreaterThan(0).And.Be(1); + response.Cookies[0].Name.Should().Be("redirectCookie"); + response.Cookies[0].Value.Should().Be("value1"); + } + + [Fact] + public async Task Can_RedirectForwardHeadersFalseWithoutCookie_DropHeaders() { + var options = NewOptions(); + options.RedirectOptions.ForwardHeaders = false; + options.RedirectOptions.ForwardCookies = false; + var client = new RestClient(options); + + // This request sets cookies and redirects to url param value + // if supplied, otherwise redirects to /get-cookies + var request = new RestRequest("/get-cookies-redirect") { + Method = Method.Get, + }; + request.AddHeader("Authorization", "blah"); + request.AddQueryParameter("url", "/dump-headers"); + + var response = await client.ExecuteAsync(request); + response.ResponseUri.Should().Be($"{_baseUri}dump-headers"); + var content = response.Content; + content.Should().NotContain("'Accept':"); + content.Should().NotContain("'User-Agent':"); + content.Should().NotContain("'Authorization':"); + // This is expected due to redirection options for this test: content.Should().NotContain("'Cookie':"); + // NOTE: This is expected to be there for normal HTTP purposes + // and is expected to be re-added by the underlying HttpClient: + content.Should().Contain("'Host':"); + // NOTE: options.AutomaticDecompression controls + // Accept-Encoding, so since we did nothing to change that + // the underlying HttpClient will re-add this header: + content.Should().Contain("'Accept-Encoding':"); // Verify the cookie exists from the redirected get: response.Cookies.Count.Should().BeGreaterThan(0).And.Be(1); @@ -48,5 +130,40 @@ public async Task Can_RedirectForwardHeadersFalse_DropHeaders() { response.Cookies[0].Value.Should().Be("value1"); } + [Fact] + public async Task Can_RedirectWithForwardCookieFalse() { + var options = NewOptions(); + options.RedirectOptions.ForwardAuthorization = true; + options.RedirectOptions.ForwardCookies = false; + var client = new RestClient(options); + + // This request sets cookies and redirects to url param value + // if supplied, otherwise redirects to /get-cookies + var request = new RestRequest("/get-cookies-redirect") { + Method = Method.Get, + }; + request.AddHeader("Authorization", "blah"); + request.AddQueryParameter("url", "/dump-headers"); + + var response = await client.ExecuteAsync(request); + response.ResponseUri.Should().Be($"{_baseUri}dump-headers"); + var content = response.Content; + // This is expected due to redirection options for this test: + content.Should().NotContain("'Cookie':"); + // These should exist: + content.Should().Contain("'Accept':"); + content.Should().Contain("'User-Agent':"); + content.Should().Contain("'Authorization':"); + content.Should().Contain("'Host':"); + content.Should().Contain("'Accept-Encoding':"); + + // Regardless of ForwardCookie, the cookie container is ALWAYS + // updated: + + // Verify the cookie exists from the redirected get: + response.Cookies.Count.Should().BeGreaterThan(0).And.Be(1); + response.Cookies[0].Name.Should().Be("redirectCookie"); + response.Cookies[0].Value.Should().Be("value1"); + } } } diff --git a/test/RestSharp.Tests.Integrated/RedirectTests.cs b/test/RestSharp.Tests.Integrated/RedirectTests.cs index ced7fe632..d817cf334 100644 --- a/test/RestSharp.Tests.Integrated/RedirectTests.cs +++ b/test/RestSharp.Tests.Integrated/RedirectTests.cs @@ -125,12 +125,6 @@ public async Task Can_ForwardHeadersTrue_OnRedirect() { } // Needed tests: - //Test: ForwardHeaders = false - //Test: ForwardHeaders = true (default) might not need separate test - //Test: ForwardAuthorization = true - //Test: ForwardAuthorization = false (default) probably need separate test - //Test: ForwardCookies = true (might already have test) - //Test: ForwardCookies = false //Test: ForwardBody = true (default, might not need test) //Test: ForwardBody = false //Test: ForceForwardBody = false (default, might not need test) From 73df3668c48db4bf210a4000a5908c3d9be4fd55 Mon Sep 17 00:00:00 2001 From: tuttb Date: Mon, 11 Mar 2024 20:27:58 -0400 Subject: [PATCH 19/24] Add RedirectOptions.ForwardQuery support and tests. --- src/RestSharp/RestClient.Async.cs | 10 +++++ .../RedirectOptionsTest.cs | 41 +++++++++++++++++-- .../RedirectTests.cs | 4 +- .../Fixtures/SimpleServer.cs | 5 +++ 4 files changed, 53 insertions(+), 7 deletions(-) diff --git a/src/RestSharp/RestClient.Async.cs b/src/RestSharp/RestClient.Async.cs index cc8e9da3f..40e3dda67 100644 --- a/src/RestSharp/RestClient.Async.cs +++ b/src/RestSharp/RestClient.Async.cs @@ -148,6 +148,16 @@ async Task ExecuteRequestAsync(RestRequest request, CancellationTo location = new Uri(url, location); } + if (Options.RedirectOptions.ForwardQuery) { + string oringalQuery = originalUrl.Query; + if (!string.IsNullOrEmpty(oringalQuery)) { + if (oringalQuery[0] == '?') { + oringalQuery = oringalQuery.Substring(1, oringalQuery.Length - 1); + } + location = location.AddQueryString(oringalQuery); + } + } + // Mirror HttpClient redirection behavior as of 07/25/2023: // Per https://tools.ietf.org/html/rfc7231#section-7.1.2, a redirect location without a // fragment should inherit the fragment from the original URI. diff --git a/test/RestSharp.Tests.Integrated/RedirectOptionsTest.cs b/test/RestSharp.Tests.Integrated/RedirectOptionsTest.cs index a831e2594..e3287b3fa 100644 --- a/test/RestSharp.Tests.Integrated/RedirectOptionsTest.cs +++ b/test/RestSharp.Tests.Integrated/RedirectOptionsTest.cs @@ -36,7 +36,7 @@ public async Task Can_RedirectForwardHeadersFalseWithAuthAndCookie_DropHeaders() request.AddQueryParameter("url", "/dump-headers"); var response = await client.ExecuteAsync(request); - response.ResponseUri.Should().Be($"{_baseUri}dump-headers"); + response.ResponseUri.Should().Be($"{_baseUri}dump-headers?url=%2fdump-headers"); var content = response.Content; content.Should().NotContain("'Accept':"); content.Should().NotContain("'User-Agent':"); @@ -72,7 +72,7 @@ public async Task Can_RedirectForwardHeadersFalseWithCookie_DropHeaders() { request.AddQueryParameter("url", "/dump-headers"); var response = await client.ExecuteAsync(request); - response.ResponseUri.Should().Be($"{_baseUri}dump-headers"); + response.ResponseUri.Should().Be($"{_baseUri}dump-headers?url=%2fdump-headers"); var content = response.Content; content.Should().NotContain("'Accept':"); content.Should().NotContain("'User-Agent':"); @@ -109,7 +109,7 @@ public async Task Can_RedirectForwardHeadersFalseWithoutCookie_DropHeaders() { request.AddQueryParameter("url", "/dump-headers"); var response = await client.ExecuteAsync(request); - response.ResponseUri.Should().Be($"{_baseUri}dump-headers"); + response.ResponseUri.Should().Be($"{_baseUri}dump-headers?url=%2fdump-headers"); var content = response.Content; content.Should().NotContain("'Accept':"); content.Should().NotContain("'User-Agent':"); @@ -146,7 +146,7 @@ public async Task Can_RedirectWithForwardCookieFalse() { request.AddQueryParameter("url", "/dump-headers"); var response = await client.ExecuteAsync(request); - response.ResponseUri.Should().Be($"{_baseUri}dump-headers"); + response.ResponseUri.Should().Be($"{_baseUri}dump-headers?url=%2fdump-headers"); var content = response.Content; // This is expected due to redirection options for this test: content.Should().NotContain("'Cookie':"); @@ -165,5 +165,38 @@ public async Task Can_RedirectWithForwardCookieFalse() { response.Cookies[0].Name.Should().Be("redirectCookie"); response.Cookies[0].Value.Should().Be("value1"); } + + [Fact] + public async Task Can_RedirectWithForwardQueryFalse() { + var options = NewOptions(); + options.RedirectOptions.ForwardQuery = false; + var client = new RestClient(options); + + // This request sets cookies and redirects to url param value + // if supplied, otherwise redirects to /get-cookies + var request = new RestRequest("/get-cookies-redirect") { + Method = Method.Get, + }; + request.AddQueryParameter("url", "/dump-headers"); + + var response = await client.ExecuteAsync(request); + response.ResponseUri.Should().Be($"{_baseUri}dump-headers"); + var content = response.Content; + // This is expected due to redirection options for this test: + content.Should().Contain("'Cookie':"); + // These should exist: + content.Should().Contain("'Accept':"); + content.Should().Contain("'User-Agent':"); + content.Should().Contain("'Host':"); + content.Should().Contain("'Accept-Encoding':"); + + // Regardless of ForwardCookie, the cookie container is ALWAYS + // updated: + + // Verify the cookie exists from the redirected get: + response.Cookies.Count.Should().BeGreaterThan(0).And.Be(1); + response.Cookies[0].Name.Should().Be("redirectCookie"); + response.Cookies[0].Value.Should().Be("value1"); + } } } diff --git a/test/RestSharp.Tests.Integrated/RedirectTests.cs b/test/RestSharp.Tests.Integrated/RedirectTests.cs index d817cf334..fb0a2560c 100644 --- a/test/RestSharp.Tests.Integrated/RedirectTests.cs +++ b/test/RestSharp.Tests.Integrated/RedirectTests.cs @@ -110,7 +110,7 @@ public async Task Can_ForwardHeadersTrue_OnRedirect() { request.AddQueryParameter("url", "/dump-headers"); var response = await _client.ExecuteAsync(request); - response.ResponseUri.Should().Be($"{_client.Options.BaseUrl}dump-headers"); + response.ResponseUri.Should().Be($"{_client.Options.BaseUrl}dump-headers?url=%2fdump-headers"); var content = response.Content; content.Should().Contain("'Accept':"); content.Should().Contain($"'Host': {_client.Options.BaseHost}"); @@ -128,8 +128,6 @@ public async Task Can_ForwardHeadersTrue_OnRedirect() { //Test: ForwardBody = true (default, might not need test) //Test: ForwardBody = false //Test: ForceForwardBody = false (default, might not need test) - //Test: ForwardQuery = true (default, might not need test) - //Test: ForwardQuery = false //Test: MaxRedirects //Test: ForwardFragment = true (default) //Test: ForwardFragment = false diff --git a/test/RestSharp.Tests.Shared/Fixtures/SimpleServer.cs b/test/RestSharp.Tests.Shared/Fixtures/SimpleServer.cs index d53863c2f..ad2373ebf 100644 --- a/test/RestSharp.Tests.Shared/Fixtures/SimpleServer.cs +++ b/test/RestSharp.Tests.Shared/Fixtures/SimpleServer.cs @@ -32,7 +32,12 @@ public static SimpleServer Create( Action handler = null, AuthenticationSchemes authenticationSchemes = AuthenticationSchemes.Anonymous ) { + TryAgain: var port = Random.Next(1000, 9999); + // Don't use Fiddler's default port: + if (port == 8888) { + goto TryAgain; + } return new SimpleServer(port, handler, authenticationSchemes); } From aecb8bb6d8918910efee56fea30cfe0731b97561 Mon Sep 17 00:00:00 2001 From: tuttb Date: Mon, 11 Mar 2024 23:21:29 -0400 Subject: [PATCH 20/24] Fix Fragment handling with AddQueryString extension, and add ForwardFragment tests. --- src/RestSharp/Request/UriExtensions.cs | 15 ++- src/RestSharp/RestClient.Async.cs | 5 +- .../RedirectOptionsTest.cs | 111 +++++++++++++++++- .../RedirectTests.cs | 2 - 4 files changed, 124 insertions(+), 9 deletions(-) diff --git a/src/RestSharp/Request/UriExtensions.cs b/src/RestSharp/Request/UriExtensions.cs index 95ad66611..141248df9 100644 --- a/src/RestSharp/Request/UriExtensions.cs +++ b/src/RestSharp/Request/UriExtensions.cs @@ -37,10 +37,17 @@ public static Uri MergeBaseUrlAndResource(this Uri? baseUrl, string? resource) { public static Uri AddQueryString(this Uri uri, string? query) { if (query == null) return uri; - var absoluteUri = uri.AbsoluteUri; - var separator = absoluteUri.Contains('?') ? "&" : "?"; - - return new Uri($"{absoluteUri}{separator}{query}"); + var absoluteUri = uri.AbsoluteUri; + var fragment = string.Empty; + if (!string.IsNullOrEmpty(uri.Fragment)) { + int fragmentStartIndex = absoluteUri.LastIndexOf(uri.Fragment); + if (fragmentStartIndex != -1) { + fragment = absoluteUri.Substring(fragmentStartIndex, absoluteUri.Length - fragmentStartIndex); + absoluteUri = absoluteUri.Substring(0, fragmentStartIndex); + } + } + var separator = string.IsNullOrEmpty(uri.Query) ? "?" : "&"; //absoluteUri.Contains('?') ? "&" : "?"; + return new Uri($"{absoluteUri}{separator}{query}{fragment}"); } public static UrlSegmentParamsValues GetUrlSegmentParamsValues( diff --git a/src/RestSharp/RestClient.Async.cs b/src/RestSharp/RestClient.Async.cs index 40e3dda67..e57761684 100644 --- a/src/RestSharp/RestClient.Async.cs +++ b/src/RestSharp/RestClient.Async.cs @@ -150,7 +150,10 @@ async Task ExecuteRequestAsync(RestRequest request, CancellationTo if (Options.RedirectOptions.ForwardQuery) { string oringalQuery = originalUrl.Query; - if (!string.IsNullOrEmpty(oringalQuery)) { + if (!string.IsNullOrEmpty(oringalQuery) + && string.IsNullOrEmpty(location.Query)) { + // AddQueryString DOES NOT want the ? in the supplied parameter, + // so strip it: if (oringalQuery[0] == '?') { oringalQuery = oringalQuery.Substring(1, oringalQuery.Length - 1); } diff --git a/test/RestSharp.Tests.Integrated/RedirectOptionsTest.cs b/test/RestSharp.Tests.Integrated/RedirectOptionsTest.cs index e3287b3fa..fe0d3c347 100644 --- a/test/RestSharp.Tests.Integrated/RedirectOptionsTest.cs +++ b/test/RestSharp.Tests.Integrated/RedirectOptionsTest.cs @@ -166,6 +166,35 @@ public async Task Can_RedirectWithForwardCookieFalse() { response.Cookies[0].Value.Should().Be("value1"); } + [Fact] + public async Task Can_RedirectWithForwardQueryWithRedirectLocationContainingQuery() { + var options = NewOptions(); + var client = new RestClient(options); + + // This request sets cookies and redirects to url param value + // if supplied, otherwise redirects to /get-cookies + var request = new RestRequest("/get-cookies-redirect") { + Method = Method.Get, + }; + request.AddQueryParameter("url", "/dump-headers?blah=blah2"); + + var response = await client.ExecuteAsync(request); + response.ResponseUri.Should().Be($"{_baseUri}dump-headers?blah=blah2"); + var content = response.Content; + // This is expected due to redirection options for this test: + content.Should().Contain("'Cookie':"); + // These should exist: + content.Should().Contain("'Accept':"); + content.Should().Contain("'User-Agent':"); + content.Should().Contain("'Host':"); + content.Should().Contain("'Accept-Encoding':"); + + // Verify the cookie exists from the redirected get: + response.Cookies.Count.Should().BeGreaterThan(0).And.Be(1); + response.Cookies[0].Name.Should().Be("redirectCookie"); + response.Cookies[0].Value.Should().Be("value1"); + } + [Fact] public async Task Can_RedirectWithForwardQueryFalse() { var options = NewOptions(); @@ -190,8 +219,86 @@ public async Task Can_RedirectWithForwardQueryFalse() { content.Should().Contain("'Host':"); content.Should().Contain("'Accept-Encoding':"); - // Regardless of ForwardCookie, the cookie container is ALWAYS - // updated: + // Verify the cookie exists from the redirected get: + response.Cookies.Count.Should().BeGreaterThan(0).And.Be(1); + response.Cookies[0].Name.Should().Be("redirectCookie"); + response.Cookies[0].Value.Should().Be("value1"); + } + + [Fact] + public async Task Can_RedirectWithForwardFragment() { + var options = NewOptions(); + var client = new RestClient(options); + + // This request sets cookies and redirects to url param value + // if supplied, otherwise redirects to /get-cookies + var request = new RestRequest("/get-cookies-redirect#fragmentName") { + Method = Method.Get, + }; + request.AddQueryParameter("url", "/dump-headers"); + + var response = await client.ExecuteAsync(request); + response.ResponseUri.Should().Be($"{_baseUri}dump-headers?url=%2fdump-headers#fragmentName"); + var content = response.Content; + // This is expected due to redirection options for this test: + content.Should().Contain("'Cookie':"); + // These should exist: + content.Should().Contain("'Accept':"); + content.Should().Contain("'User-Agent':"); + content.Should().Contain("'Host':"); + content.Should().Contain("'Accept-Encoding':"); + + // Verify the cookie exists from the redirected get: + response.Cookies.Count.Should().BeGreaterThan(0).And.Be(1); + response.Cookies[0].Name.Should().Be("redirectCookie"); + response.Cookies[0].Value.Should().Be("value1"); + } + + [Fact] + public async Task Can_RedirectWithForwardFragmentFalse() { + var options = NewOptions(); + options.RedirectOptions.ForwardFragment = false; + var client = new RestClient(options); + + // This request sets cookies and redirects to url param value + // if supplied, otherwise redirects to /get-cookies + var request = new RestRequest("/get-cookies-redirect#fragmentName") { + Method = Method.Get, + }; + request.AddQueryParameter("url", "/dump-headers"); + + var response = await client.ExecuteAsync(request); + response.ResponseUri.Should().Be($"{_baseUri}dump-headers?url=%2fdump-headers"); + var content = response.Content; + // This is expected due to redirection options for this test: + content.Should().Contain("'Cookie':"); + // These should exist: + content.Should().Contain("'Accept':"); + content.Should().Contain("'User-Agent':"); + content.Should().Contain("'Host':"); + content.Should().Contain("'Accept-Encoding':"); + + // Verify the cookie exists from the redirected get: + response.Cookies.Count.Should().BeGreaterThan(0).And.Be(1); + response.Cookies[0].Name.Should().Be("redirectCookie"); + response.Cookies[0].Value.Should().Be("value1"); + } + + [Fact] + public async Task Can_RedirectWithForwardFragmentWithoutQuery() { + var options = NewOptions(); + var client = new RestClient(options); + + // This request sets cookies and redirects to url param value + // if supplied, otherwise redirects to /get-cookies + var request = new RestRequest("/get-cookies-redirect#fragmentName") { + Method = Method.Get, + }; + + var response = await client.ExecuteAsync(request); + response.ResponseUri.Should().Be($"{_baseUri}get-cookies#fragmentName"); + var content = response.Content; + content.Should().Contain("redirectCookie=value1"); // Verify the cookie exists from the redirected get: response.Cookies.Count.Should().BeGreaterThan(0).And.Be(1); diff --git a/test/RestSharp.Tests.Integrated/RedirectTests.cs b/test/RestSharp.Tests.Integrated/RedirectTests.cs index fb0a2560c..fdc3811a1 100644 --- a/test/RestSharp.Tests.Integrated/RedirectTests.cs +++ b/test/RestSharp.Tests.Integrated/RedirectTests.cs @@ -129,8 +129,6 @@ public async Task Can_ForwardHeadersTrue_OnRedirect() { //Test: ForwardBody = false //Test: ForceForwardBody = false (default, might not need test) //Test: MaxRedirects - //Test: ForwardFragment = true (default) - //Test: ForwardFragment = false //Test: AllowRedirectMethodStatusCodeToAlterVerb = true (default, might not need test) //Test: AllowRedirectMethodStatusCodeToAlterVerb = false //Test: Altered Redirect Status Codes list From 7b969f5f306c3bd021aee6637ea7e3a8875aa943 Mon Sep 17 00:00:00 2001 From: tuttb Date: Tue, 12 Mar 2024 00:27:13 -0400 Subject: [PATCH 21/24] Add RedirectOptions.MaxRedirects support and tests. --- .../Options/RestClientRedirectionOptions.cs | 2 +- src/RestSharp/RestClient.Async.cs | 6 ++ .../RedirectOptionsTest.cs | 98 +++++++++++++++++++ .../RedirectTests.cs | 1 - .../Server/TestServer.cs | 15 ++- 5 files changed, 119 insertions(+), 3 deletions(-) diff --git a/src/RestSharp/Options/RestClientRedirectionOptions.cs b/src/RestSharp/Options/RestClientRedirectionOptions.cs index f7908a3e6..ea76dd563 100644 --- a/src/RestSharp/Options/RestClientRedirectionOptions.cs +++ b/src/RestSharp/Options/RestClientRedirectionOptions.cs @@ -83,7 +83,7 @@ public class RestClientRedirectionOptions { /// /// The maximum number of redirects to follow. /// - public int MaxRedirects { get; set; } = 10; + public int MaxRedirects { get; set; } = 50; /// /// Set to true (default), to supply any requested fragment portion of the original URL to the destination URL. diff --git a/src/RestSharp/RestClient.Async.cs b/src/RestSharp/RestClient.Async.cs index e57761684..f2dc26870 100644 --- a/src/RestSharp/RestClient.Async.cs +++ b/src/RestSharp/RestClient.Async.cs @@ -109,6 +109,7 @@ async Task ExecuteRequestAsync(RestRequest request, CancellationTo bool foundCookies = false; bool firstAttempt = true; + int redirectCount = 0; do { using var requestContent = new RequestContent(this, request); @@ -144,6 +145,11 @@ async Task ExecuteRequestAsync(RestRequest request, CancellationTo break; } + redirectCount++; + if (redirectCount >= Options.RedirectOptions.MaxRedirects) { + break; + } + if (!location.IsAbsoluteUri) { location = new Uri(url, location); } diff --git a/test/RestSharp.Tests.Integrated/RedirectOptionsTest.cs b/test/RestSharp.Tests.Integrated/RedirectOptionsTest.cs index fe0d3c347..9f86fc2d2 100644 --- a/test/RestSharp.Tests.Integrated/RedirectOptionsTest.cs +++ b/test/RestSharp.Tests.Integrated/RedirectOptionsTest.cs @@ -305,5 +305,103 @@ public async Task Can_RedirectWithForwardFragmentWithoutQuery() { response.Cookies[0].Name.Should().Be("redirectCookie"); response.Cookies[0].Value.Should().Be("value1"); } + + [Fact] + public async Task Can_RedirectBelowMaxRedirects_WithLoweredValue() { + var options = NewOptions(); + options.RedirectOptions.MaxRedirects = 6; + var client = new RestClient(options); + + // This request sets cookies and redirects to url param value + // if supplied, otherwise redirects to /get-cookies + var request = new RestRequest("/redirect-countdown") { + Method = Method.Get, + }; + request.AddQueryParameter("n", "20"); + + var response = await client.ExecuteAsync(request); + HeaderParameter locationHeader = null; + response.ResponseUri.Should().Be($"{_baseUri}redirect-countdown?n=15"); + response.Headers.Should().Contain((header) => string.Compare(header.Name, "Location", StringComparison.InvariantCultureIgnoreCase) == 0); + locationHeader = (from header in response.Headers + where string.Compare(header.Name, "Location", StringComparison.InvariantCultureIgnoreCase) == 0 + select header).First(); + locationHeader.Value.Should().Be("/redirect-countdown?n=14"); + var content = response.Content; + content.Should().NotContain("Stopped redirection countdown!"); + } + + [Fact] + public async Task Can_RedirectBelowMaxRedirects_WithDefault() { + var options = NewOptions(); + var client = new RestClient(options); + + // This request sets cookies and redirects to url param value + // if supplied, otherwise redirects to /get-cookies + var request = new RestRequest("/redirect-countdown") { + Method = Method.Get, + }; + request.AddQueryParameter("n", "20"); + + var response = await client.ExecuteAsync(request); + response.ResponseUri.Should().Be($"{_baseUri}redirect-countdown?n=1"); + var content = response.Content; + content.Should().Contain("Stopped redirection countdown!"); + } + + [Fact] + public async Task Can_RedirectAtMaxRedirects() { + var options = NewOptions(); + var client = new RestClient(options); + + // This request sets cookies and redirects to url param value + // if supplied, otherwise redirects to /get-cookies + var request = new RestRequest("/redirect-countdown") { + Method = Method.Get, + }; + request.AddQueryParameter("n", "50"); + + var response = await client.ExecuteAsync(request); + response.ResponseUri.Should().Be($"{_baseUri}redirect-countdown?n=1"); + var content = response.Content; + content.Should().Contain("Stopped redirection countdown!"); + } + + [Fact] + public async Task Can_StopRedirectAboveMaxRedirectDefault() { + var options = NewOptions(); + var client = new RestClient(options); + + // This request sets cookies and redirects to url param value + // if supplied, otherwise redirects to /get-cookies + var request = new RestRequest("/redirect-countdown") { + Method = Method.Get, + }; + request.AddQueryParameter("n", "51"); + + var response = await client.ExecuteAsync(request); + response.ResponseUri.Should().Be($"{_baseUri}redirect-countdown?n=2"); + var content = response.Content; + content.Should().NotContain("Stopped redirection countdown!"); + } + + [Fact] + public async Task Can_StopRedirectAboveMaxRedirectSet() { + var options = NewOptions(); + options.RedirectOptions.MaxRedirects = 5; + var client = new RestClient(options); + + // This request sets cookies and redirects to url param value + // if supplied, otherwise redirects to /get-cookies + var request = new RestRequest("/redirect-countdown") { + Method = Method.Get, + }; + request.AddQueryParameter("n", "6"); + + var response = await client.ExecuteAsync(request); + response.ResponseUri.Should().Be($"{_baseUri}redirect-countdown?n=2"); + var content = response.Content; + content.Should().NotContain("Stopped redirection countdown!"); + } } } diff --git a/test/RestSharp.Tests.Integrated/RedirectTests.cs b/test/RestSharp.Tests.Integrated/RedirectTests.cs index fdc3811a1..b52869d4e 100644 --- a/test/RestSharp.Tests.Integrated/RedirectTests.cs +++ b/test/RestSharp.Tests.Integrated/RedirectTests.cs @@ -128,7 +128,6 @@ public async Task Can_ForwardHeadersTrue_OnRedirect() { //Test: ForwardBody = true (default, might not need test) //Test: ForwardBody = false //Test: ForceForwardBody = false (default, might not need test) - //Test: MaxRedirects //Test: AllowRedirectMethodStatusCodeToAlterVerb = true (default, might not need test) //Test: AllowRedirectMethodStatusCodeToAlterVerb = false //Test: Altered Redirect Status Codes list diff --git a/test/RestSharp.Tests.Integrated/Server/TestServer.cs b/test/RestSharp.Tests.Integrated/Server/TestServer.cs index f5d38dc81..9719c3840 100644 --- a/test/RestSharp.Tests.Integrated/Server/TestServer.cs +++ b/test/RestSharp.Tests.Integrated/Server/TestServer.cs @@ -40,7 +40,20 @@ public HttpServer(ITestOutputHelper? output = null) { _app.MapGet("headers", HeaderHandlers.HandleHeaders); _app.MapGet("request-echo", async context => await context.Request.BodyReader.AsStream().CopyToAsync(context.Response.BodyWriter.AsStream())); _app.MapDelete("delete", () => new TestResponse { Message = "Works!" }); - _app.MapGet("dump-headers", + _app.MapGet("redirect-countdown", + (HttpContext ctx) => { + string redirectDestination = "/redirect-countdown"; + var queryString = HttpUtility.ParseQueryString(ctx.Request.QueryString.Value); + int redirectsLeft = -1; + redirectsLeft = int.Parse(queryString.Get("n")); + if (redirectsLeft != -1 + && redirectsLeft > 1) { + redirectDestination = $"{redirectDestination}?n={redirectsLeft - 1}"; + return Results.Redirect(redirectDestination, false, true); + } + return Results.Ok("Stopped redirection countdown!"); + }); + _app.MapGet("dump-headers", (HttpContext ctx) => { var headers = ctx.Request.Headers; StringBuilder sb = new StringBuilder(); From 950be06c9dd8fe74547cd303b9c03465eacaf0b2 Mon Sep 17 00:00:00 2001 From: tuttb Date: Tue, 12 Mar 2024 01:58:29 -0400 Subject: [PATCH 22/24] Add secure test server end point to TestServer. Add secure test server untrusted certificate. Out of paranoia, don't let SimpleServer use either the secure or insecure ports from TestServer. Add tests both positive and negative for RedirectOptions.FollowRedirectsToInsecure Remember to override options.RemoteCertificateValidationCallback if you get a certificate validation error in new tests. --- .../RedirectOptionsTest.cs | 77 +++++++++++++++++- .../RedirectTests.cs | 3 - .../RestSharp.Tests.Integrated.csproj | 3 + .../Server/TestServer.cs | 18 +++- .../Server/testCert.pfx | Bin 0 -> 785 bytes .../Fixtures/SimpleServer.cs | 7 +- 6 files changed, 101 insertions(+), 7 deletions(-) create mode 100644 test/RestSharp.Tests.Integrated/Server/testCert.pfx diff --git a/test/RestSharp.Tests.Integrated/RedirectOptionsTest.cs b/test/RestSharp.Tests.Integrated/RedirectOptionsTest.cs index 9f86fc2d2..1c93d3cb4 100644 --- a/test/RestSharp.Tests.Integrated/RedirectOptionsTest.cs +++ b/test/RestSharp.Tests.Integrated/RedirectOptionsTest.cs @@ -2,6 +2,8 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; using System.Text; using System.Threading.Tasks; @@ -10,9 +12,11 @@ namespace RestSharp.Tests.Integrated { public class RedirectOptionsTest { readonly string _host; readonly Uri _baseUri; + readonly Uri _baseSecureUri; public RedirectOptionsTest(TestServerFixture fixture) { _baseUri = fixture.Server.Url; + _baseSecureUri = fixture.Server.SecureUrl; _host = _baseUri.Host; } @@ -320,8 +324,8 @@ public async Task Can_RedirectBelowMaxRedirects_WithLoweredValue() { request.AddQueryParameter("n", "20"); var response = await client.ExecuteAsync(request); - HeaderParameter locationHeader = null; response.ResponseUri.Should().Be($"{_baseUri}redirect-countdown?n=15"); + HeaderParameter locationHeader = null; response.Headers.Should().Contain((header) => string.Compare(header.Name, "Location", StringComparison.InvariantCultureIgnoreCase) == 0); locationHeader = (from header in response.Headers where string.Compare(header.Name, "Location", StringComparison.InvariantCultureIgnoreCase) == 0 @@ -403,5 +407,76 @@ public async Task Can_StopRedirectAboveMaxRedirectSet() { var content = response.Content; content.Should().NotContain("Stopped redirection countdown!"); } + + // Custom logic that can either override or extends the .NET validation logic + private static bool RemoteCertificateValidationCallback(object sender, X509Certificate certificate, + X509Chain chain, + SslPolicyErrors sslPolicyErrors) { + return true; + } + + [Fact] + public async Task Can_FailToRedirectToInsecureUrl() { + var options = NewOptions(); + options.RemoteCertificateValidationCallback = RemoteCertificateValidationCallback; + var client = new RestClient(options); + + // This request sets cookies and redirects to url param value + // if supplied, otherwise redirects to /get-cookies + var request = new RestRequest($"{_baseSecureUri}redirect-insecure") { + Method = Method.Get, + }; + + var response = await client.ExecuteAsync(request); + response.ResponseUri.Should().NotBe($"{_baseUri}dump-headers"); + response.ResponseUri.Should().Be($"{_baseSecureUri}redirect-insecure"); + HeaderParameter locationHeader = null; + response.Headers.Should().Contain((header) => string.Compare(header.Name, "Location", StringComparison.InvariantCultureIgnoreCase) == 0); + locationHeader = (from header in response.Headers + where string.Compare(header.Name, "Location", StringComparison.InvariantCultureIgnoreCase) == 0 + select header).First(); + locationHeader.Value.Should().Be($"{_baseUri}dump-headers"); + } + + [Fact] + public async Task Can_RedirectToInsecureUrlWithRedirectOption() { + var options = NewOptions(); + options.RemoteCertificateValidationCallback = RemoteCertificateValidationCallback; + options.RedirectOptions.FollowRedirectsToInsecure = true; + var client = new RestClient(options); + + // This request sets cookies and redirects to url param value + // if supplied, otherwise redirects to /get-cookies + var request = new RestRequest($"{_baseSecureUri}redirect-insecure") { + Method = Method.Get, + }; + + var response = await client.ExecuteAsync(request); + response.ResponseUri.Should().Be($"{_baseUri}dump-headers"); + response.ResponseUri.Should().NotBe($"{_baseSecureUri}redirect-insecure"); + } + + [Fact] + public async Task Can_RedirectToSecureUrl() { + var options = NewOptions(); + options.RemoteCertificateValidationCallback = RemoteCertificateValidationCallback; + options.RedirectOptions.FollowRedirectsToInsecure = true; + var client = new RestClient(options); + + // This request sets cookies and redirects to url param value + // if supplied, otherwise redirects to /get-cookies + var request = new RestRequest($"{_baseUri}redirect-secure") { + Method = Method.Get, + }; + + var response = await client.ExecuteAsync(request); + response.ResponseUri.Should().Be($"{_baseSecureUri}dump-headers"); + response.ResponseUri.Should().NotBe($"{_baseUri}redirect-insecure"); + var content = response.Content; + content.Should().Contain("'Accept':"); + content.Should().Contain("'User-Agent':"); + content.Should().Contain("'Host':"); + content.Should().Contain("'Accept-Encoding':"); + } } } diff --git a/test/RestSharp.Tests.Integrated/RedirectTests.cs b/test/RestSharp.Tests.Integrated/RedirectTests.cs index b52869d4e..a149c60d9 100644 --- a/test/RestSharp.Tests.Integrated/RedirectTests.cs +++ b/test/RestSharp.Tests.Integrated/RedirectTests.cs @@ -132,9 +132,6 @@ public async Task Can_ForwardHeadersTrue_OnRedirect() { //Test: AllowRedirectMethodStatusCodeToAlterVerb = false //Test: Altered Redirect Status Codes list //Test: FollowRedirects = false - // Problem: Need secure test server: - //Test: FollowRedirectsToInsecure = true - //Test: FollowRedirectsToInsecure = false class Response { diff --git a/test/RestSharp.Tests.Integrated/RestSharp.Tests.Integrated.csproj b/test/RestSharp.Tests.Integrated/RestSharp.Tests.Integrated.csproj index a4058ba25..4974fd7b6 100644 --- a/test/RestSharp.Tests.Integrated/RestSharp.Tests.Integrated.csproj +++ b/test/RestSharp.Tests.Integrated/RestSharp.Tests.Integrated.csproj @@ -25,6 +25,9 @@ + + PreserveNewest + \ No newline at end of file diff --git a/test/RestSharp.Tests.Integrated/Server/TestServer.cs b/test/RestSharp.Tests.Integrated/Server/TestServer.cs index 9719c3840..6be0ccf2b 100644 --- a/test/RestSharp.Tests.Integrated/Server/TestServer.cs +++ b/test/RestSharp.Tests.Integrated/Server/TestServer.cs @@ -6,6 +6,8 @@ using RestSharp.Tests.Integrated.Server.Handlers; using RestSharp.Tests.Shared.Extensions; using System.Net; +using System.Reflection; +using System.Security.Cryptography.X509Certificates; using System.Text; using System.Web; @@ -17,6 +19,7 @@ public sealed class HttpServer { readonly WebApplication _app; const string Address = "http://localhost:5151"; + const string SecureAddress = "https://localhost:5152"; public const string ContentResource = "content"; public const string TimeoutResource = "timeout"; @@ -26,8 +29,12 @@ public HttpServer(ITestOutputHelper? output = null) { if (output != null) builder.Logging.AddXunit(output, LogLevel.Debug); + var currentAssemblyPath = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); builder.Services.AddControllers().AddApplicationPart(typeof(UploadController).Assembly); - builder.WebHost.UseUrls(Address); + builder.WebHost.UseUrls(Address, SecureAddress).UseKestrel(options => { + options.ListenAnyIP(5151, listenOptions => { return; }); + options.ListenAnyIP(5152, listenOptions => listenOptions.UseHttps(new X509Certificate2(Path.Join(currentAssemblyPath, "Server\\testCert.pfx"), string.Empty))); + }); _app = builder.Build(); _app.MapControllers(); @@ -40,6 +47,14 @@ public HttpServer(ITestOutputHelper? output = null) { _app.MapGet("headers", HeaderHandlers.HandleHeaders); _app.MapGet("request-echo", async context => await context.Request.BodyReader.AsStream().CopyToAsync(context.Response.BodyWriter.AsStream())); _app.MapDelete("delete", () => new TestResponse { Message = "Works!" }); + _app.MapGet("redirect-insecure", (HttpContext ctx) => { + string destination = $"{Address}/dump-headers"; + return Results.Redirect(destination, false, true); + }); + _app.MapGet("redirect-secure", (HttpContext ctx) => { + string destination = $"{SecureAddress}/dump-headers"; + return Results.Redirect(destination, false, true); + }); _app.MapGet("redirect-countdown", (HttpContext ctx) => { string redirectDestination = "/redirect-countdown"; @@ -130,6 +145,7 @@ public HttpServer(ITestOutputHelper? output = null) { } public Uri Url => new(Address); + public Uri SecureUrl => new(SecureAddress); public Task Start() => _app.StartAsync(); diff --git a/test/RestSharp.Tests.Integrated/Server/testCert.pfx b/test/RestSharp.Tests.Integrated/Server/testCert.pfx new file mode 100644 index 0000000000000000000000000000000000000000..0b9980d3ceebb4880b8b9d68cff78f819225b83e GIT binary patch literal 785 zcmXqLV&*kyV*I**nTe5!iIbsa!FzL0*C1&FUN%mxHjlRNyo`+8tPBPshC&7cY|No7 z%)*>G`N@en8TrK}26E!OM#cul#s-EKh870KQ9!N<5*LTACPpP>;~7~Qn41{+8Gz2< zVrpV!WVliG%~QZ$cYkbs)6*RF#06Hz_ONX~efz&@mPLKk&ON2IAxrKb%@&C)2{L`1 z_QLYuDfx@jZ!fA)=G^x2SyA20h;`aiRPU*C3JI<>?cBFXN7(I1n@ZoR>2vn$-`UFQ z>(Z{`^dbIUc|v}yZB~7sQ`hQRr`ywBEl%2}czntkd6C!qrw15pUUMZ__*~BE*#G`M zlC#g~ewvujcGI#bL}r)o@eNg_&rT->Tkd^%H(7f5(+|$U%dV+BG|zGJ{qjM&I8~$| zW4qF)k6S(LM4Nb~^ys^0v`1HLss8`wImdUW>${hRNr=R!?)mg?xAJGf@0ZTKpT)PV z=~sP3*2&IS(USMBUyRtv#LURRxH!om!GH%C;XafKsGa874#05d(yawD&NYTa5#syTw!q{XU!U&|8kYfQD$-r1( zWDxgtF1vMl;_gZ8-&k||ij2jNhRHQN>GC_WhACpoX^xE_zOntRU$Fjs*&WF$UNx^@ zi5-(~@f_UG`PF~g?elx4SGonN_wTrKRH#+p-v5m`!VZjI8txQ+o@cIbd0)?=0^wWQ zOAc>aHqGNt_xJl}4|v%~&wr3;w^~sufBCI-pKhNi?iAu%UG}nj)$5=8{@ygr4wzkj zF!_s0r_%LwjqH!#da@rKFfh6$x>F~?Y0ew@u+RI?o|fR(5^*a%xWee0{Gm-}T{#4J z&R(rwap>|Ln*&FRLIBQMJR1N2 literal 0 HcmV?d00001 diff --git a/test/RestSharp.Tests.Shared/Fixtures/SimpleServer.cs b/test/RestSharp.Tests.Shared/Fixtures/SimpleServer.cs index ad2373ebf..ea753ac68 100644 --- a/test/RestSharp.Tests.Shared/Fixtures/SimpleServer.cs +++ b/test/RestSharp.Tests.Shared/Fixtures/SimpleServer.cs @@ -34,8 +34,11 @@ public static SimpleServer Create( ) { TryAgain: var port = Random.Next(1000, 9999); - // Don't use Fiddler's default port: - if (port == 8888) { + // Don't use Fiddler's default port, + // or the TestServer insecure/secure ports: + if (port == 8888 + || port == 5151 + || port == 5152) { goto TryAgain; } return new SimpleServer(port, handler, authenticationSchemes); From ee5e15123e35e4e54d8525bd09c178ffba93179d Mon Sep 17 00:00:00 2001 From: tuttb Date: Tue, 12 Mar 2024 01:58:52 -0400 Subject: [PATCH 23/24] minor tweaks --- test/RestSharp.Tests.Integrated/RedirectOptionsTest.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/RestSharp.Tests.Integrated/RedirectOptionsTest.cs b/test/RestSharp.Tests.Integrated/RedirectOptionsTest.cs index 1c93d3cb4..a5e5bc286 100644 --- a/test/RestSharp.Tests.Integrated/RedirectOptionsTest.cs +++ b/test/RestSharp.Tests.Integrated/RedirectOptionsTest.cs @@ -439,7 +439,7 @@ where string.Compare(header.Name, "Location", StringComparison.InvariantCultureI } [Fact] - public async Task Can_RedirectToInsecureUrlWithRedirectOption() { + public async Task Can_RedirectToInsecureUrlWithRedirectOption_True() { var options = NewOptions(); options.RemoteCertificateValidationCallback = RemoteCertificateValidationCallback; options.RedirectOptions.FollowRedirectsToInsecure = true; @@ -460,7 +460,6 @@ public async Task Can_RedirectToInsecureUrlWithRedirectOption() { public async Task Can_RedirectToSecureUrl() { var options = NewOptions(); options.RemoteCertificateValidationCallback = RemoteCertificateValidationCallback; - options.RedirectOptions.FollowRedirectsToInsecure = true; var client = new RestClient(options); // This request sets cookies and redirects to url param value From 4ca345333916ff7b8bb5925347cffc06fa3f9e4d Mon Sep 17 00:00:00 2001 From: tuttb Date: Wed, 13 Mar 2024 19:55:51 -0400 Subject: [PATCH 24/24] RestClient.Async.cs: Convert lots of bools to a [Flag] enum setup. Add changes to allow the AlterVerb... RedirectOptions to work properly. RequestContent.cs: Add support for omitting the body (due to the HTTP Verb/Method changing under redirect processing) TestServer.cs: * Add certificate password for SSL test server. * Add HTTP verb changing related routes * Add dump-request route so that the method and hearders get dumped to response content to help enable authoring RedirectOption tests. * Minor tweaks to silence nullability warnings. RedirectTests.cs: Use new (Not)ContainCookieWithNameAndValue extension methods in tests, and use .And to cleanup the repetitive assertion code. RedirectOptionsTest.cs: * Add missing StatusCode assertions * Use new (Not)ContainCookieWithNameAndValue extension methods in the tests and .And. to cleanup the assertions. * For coverage reasons add initial request cookies to Can_RedirectForwardHeadersFalseWithCookie_DropHeaders. * New tests: Options.RedirectOptions.FollowRedirects = false Options.FollowRedirects = False (back compat) AllowForcedRedirectVerbChange = false with 302 AllowForcedRedirectVerbChange = false with 303 Change verb with defaults after 302 Change verb with defaults after 303 Don't chanve verb with defaults after 307 Options.CookieContainer contains expected results after a redirection. Change verb with defaults after 303, but with ForceForwardBody so that the request body is forwarded on the new verb. Additionally, due to having VS 17.9.3 updated xunit/ms test sdk nuget pkgs that makes some of the dependabot PRs obsolete. --- .../Options/RestClientRedirectionOptions.cs | 20 +- src/RestSharp/Request/RequestContent.cs | 10 +- src/RestSharp/RestClient.Async.cs | 59 +- .../RestSharp.InteractiveTests.csproj | 2 +- .../RedirectOptionsTest.cs | 550 ++++++++++++++---- .../RedirectTests.cs | 56 +- .../RestSharp.Tests.Integrated.csproj | 6 +- .../Server/TestServer.cs | 68 ++- .../Server/testCert.pfx | Bin 785 -> 2660 bytes .../RestSharp.Tests.Serializers.Csv.csproj | 6 +- .../RestSharp.Tests.Serializers.Json.csproj | 6 +- .../RestSharp.Tests.Serializers.Xml.csproj | 6 +- .../FluentAssertionCookieExtensions.cs | 104 ++++ .../RestSharp.Tests.Shared.csproj | 2 +- test/RestSharp.Tests/RestSharp.Tests.csproj | 6 +- 15 files changed, 713 insertions(+), 188 deletions(-) create mode 100644 test/RestSharp.Tests.Shared/Extensions/FluentAssertionCookieExtensions.cs diff --git a/src/RestSharp/Options/RestClientRedirectionOptions.cs b/src/RestSharp/Options/RestClientRedirectionOptions.cs index ea76dd563..224bdeb70 100644 --- a/src/RestSharp/Options/RestClientRedirectionOptions.cs +++ b/src/RestSharp/Options/RestClientRedirectionOptions.cs @@ -54,7 +54,7 @@ public class RestClientRedirectionOptions { /// redirected URL, unless the force verb to GET behavior is triggered. /// /// - public bool ForwardBody { get; set; } = true; + public bool ForwardBody { get; set; } = false; /// /// Set to true (default is false) to force forwarding the body of the @@ -101,6 +101,24 @@ public class RestClientRedirectionOptions { /// public bool AllowRedirectMethodStatusCodeToAlterVerb { get; set; } = true; + /// + /// Set to true (default), to allow the backward compatibility behavior of + /// changing the verb to GET with non 303 redirection status codes. + /// + /// + /// NOTE: Even though the below text only references 302, this also allows some other scenarios. + /// See for the specifics. + /// Based on Wikipedia https://en.wikipedia.org/wiki/HTTP_302: + /// Many web browsers implemented this code in a manner that violated this standard, changing + /// the request type of the new request to GET, regardless of the type employed in the original request + /// (e.g. POST). For this reason, HTTP/1.1 (RFC 2616) added the new status codes 303 and 307 to disambiguate + /// between the two behaviours, with 303 mandating the change of request type to GET, and 307 preserving the + /// request type as originally sent. Despite the greater clarity provided by this disambiguation, the 302 code + /// is still employed in web frameworks to preserve compatibility with browsers that do not implement the HTTP/1.1 + /// specification. + /// + public bool AllowForcedRedirectVerbChange { get; set; } = true; + /// /// HttpStatusCodes that trigger redirect processing. Defaults to MovedPermanently (301), /// SeeOther/RedirectMethod (303), diff --git a/src/RestSharp/Request/RequestContent.cs b/src/RestSharp/Request/RequestContent.cs index 38d1ac90e..c88c3fed7 100644 --- a/src/RestSharp/Request/RequestContent.cs +++ b/src/RestSharp/Request/RequestContent.cs @@ -1,11 +1,11 @@ // Copyright (c) .NET Foundation and Contributors -// +// // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at -// +// // http://www.apache.org/licenses/LICENSE-2.0 -// +// // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -36,7 +36,7 @@ public RequestContent(RestClient client, RestRequest request) { _parameters = new RequestParameters(_request.Parameters.Union(_client.DefaultParameters)); } - public HttpContent BuildContent() { + public HttpContent BuildContent(bool omitBody = false) { var postParameters = _parameters.GetContentParameters(_request.Method).ToArray(); var postParametersExists = postParameters.Length > 0; var bodyParametersExists = _request.TryGetBodyParameter(out var bodyParameter); @@ -51,7 +51,7 @@ public HttpContent BuildContent() { if (filesExists) AddFiles(); - if (bodyParametersExists) AddBody(postParametersExists, bodyParameter!); + if (bodyParametersExists && !omitBody) AddBody(postParametersExists, bodyParameter!); if (postParametersExists) AddPostParameters(postParameters); diff --git a/src/RestSharp/RestClient.Async.cs b/src/RestSharp/RestClient.Async.cs index f2dc26870..448a00bd9 100644 --- a/src/RestSharp/RestClient.Async.cs +++ b/src/RestSharp/RestClient.Async.cs @@ -1,11 +1,11 @@ // Copyright (c) .NET Foundation and Contributors -// +// // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at -// +// // http://www.apache.org/licenses/LICENSE-2.0 -// +// // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -70,6 +70,16 @@ static RestResponse GetErrorResponse(RestRequest request, Exception exception, C bool TimedOut() => timeoutToken.IsCancellationRequested || exception.Message.Contains("HttpClient.Timeout"); } + [Flags] + private enum ExecutionState { + None = 0x0, + FoundCookie = 0x1, + FirstAttempt = 0x2, + DoNotSendBody = 0x4, + VerbAltered = 0x8, + VerbAlterationPrevented = 0x10, + }; + async Task ExecuteRequestAsync(RestRequest request, CancellationToken cancellationToken) { Ensure.NotNull(request, nameof(request)); @@ -107,23 +117,26 @@ async Task ExecuteRequestAsync(RestRequest request, CancellationTo .AddCookieHeaders(url, cookieContainer) .AddCookieHeaders(url, Options.CookieContainer); - bool foundCookies = false; - bool firstAttempt = true; + ExecutionState state = ExecutionState.FirstAttempt; int redirectCount = 0; do { + // TODO: Is there a more effecient way to do this other than rebuilding the RequestContent + // every time through this loop? using var requestContent = new RequestContent(this, request); - using var content = requestContent.BuildContent(); + using var content = requestContent.BuildContent(omitBody: state.HasFlag(ExecutionState.DoNotSendBody)); // If we found coookies during a redirect, // we need to update the Cookie headers: - if (foundCookies) { + if (state.HasFlag(ExecutionState.FoundCookie)) { headers.AddCookieHeaders(url, cookieContainer); + // Clear the state: + state &= ~ExecutionState.FoundCookie; } using var message = PrepareRequestMessage(httpMethod, url, content, headers); - if (firstAttempt) { - firstAttempt = false; + if (state.HasFlag(ExecutionState.FirstAttempt)) { + state &= ~ExecutionState.FirstAttempt; try { if (request.OnBeforeRequest != null) await request.OnBeforeRequest(message).ConfigureAwait(false); await OnBeforeRequest(message).ConfigureAwait(false); @@ -194,6 +207,10 @@ async Task ExecuteRequestAsync(RestRequest request, CancellationTo if (responseMessage.StatusCode == HttpStatusCode.RedirectMethod && Options.RedirectOptions.AllowRedirectMethodStatusCodeToAlterVerb) { httpMethod = HttpMethod.Get; + state |= ExecutionState.VerbAltered; + } + else if (responseMessage.StatusCode == HttpStatusCode.RedirectMethod) { + state |= ExecutionState.VerbAlterationPrevented; } // Based on Wikipedia https://en.wikipedia.org/wiki/HTTP_302: @@ -207,17 +224,20 @@ async Task ExecuteRequestAsync(RestRequest request, CancellationTo // NOTE: Given the above, it is not surprising that HttpClient when AllowRedirect = true // solves this problem by a helper method: - if (RedirectRequestRequiresForceGet(responseMessage.StatusCode, httpMethod)) { + if (!state.HasFlag(ExecutionState.VerbAlterationPrevented) + && ( + state.HasFlag(ExecutionState.VerbAltered) + || (Options.RedirectOptions.AllowForcedRedirectVerbChange + && RedirectRequestRequiresForceGet(responseMessage.StatusCode, httpMethod)))) { httpMethod = HttpMethod.Get; if (!Options.RedirectOptions.ForceForwardBody) { // HttpClient RedirectHandler sets request.Content to null here: - // TODO: I don't think is quite correct yet.. - // We don't necessarily want to modify the original request, but.. - // is there a way to clone it properly and then clear out what we don't - // care about? - message.Content = null; - // HttpClient Redirect handler also foribly removes + state |= ExecutionState.DoNotSendBody; + // HttpClient Redirect handler also forcibly removes // a Transfer-Encoding of chunked in this case. + // This makes sense, since without a body, there isn't any chunked (or otherwise) content + // to transmit. + // NOTE: Although, I'm not sure why it only cares about chunked... Parameter? transferEncoding = request.Parameters.TryFind(KnownHeaders.TransferEncoding); if (transferEncoding != null && transferEncoding.Type == ParameterType.HttpHeader @@ -233,7 +253,7 @@ async Task ExecuteRequestAsync(RestRequest request, CancellationTo // cookies, the CookieContainer will be updated: if (responseMessage.Headers.TryGetValues(KnownHeaders.SetCookie, out var cookiesHeader1)) { if (Options.RedirectOptions.ForwardCookies) { - foundCookies = true; + state |= ExecutionState.FoundCookie; } // ReSharper disable once PossibleMultipleEnumeration cookieContainer.AddCookies(url, cookiesHeader1); @@ -264,7 +284,7 @@ async Task ExecuteRequestAsync(RestRequest request, CancellationTo continue; } // Otherwise: schedule the items for removal: - headersToRemove.Add(param.Name); + headersToRemove.Add(param.Name!); } } if (headersToRemove.Count > 0) { @@ -357,8 +377,7 @@ internal static bool IsSocksScheme(string scheme) => /// /// /// - /// - /// + /// Returns true if statusCode requires a verb change to Get. private bool RedirectRequestRequiresForceGet(HttpStatusCode statusCode, HttpMethod httpMethod) { return statusCode switch { HttpStatusCode.Moved or HttpStatusCode.Found or HttpStatusCode.MultipleChoices diff --git a/test/RestSharp.InteractiveTests/RestSharp.InteractiveTests.csproj b/test/RestSharp.InteractiveTests/RestSharp.InteractiveTests.csproj index d86d135bf..38ab23946 100644 --- a/test/RestSharp.InteractiveTests/RestSharp.InteractiveTests.csproj +++ b/test/RestSharp.InteractiveTests/RestSharp.InteractiveTests.csproj @@ -14,6 +14,6 @@ - + diff --git a/test/RestSharp.Tests.Integrated/RedirectOptionsTest.cs b/test/RestSharp.Tests.Integrated/RedirectOptionsTest.cs index a5e5bc286..707c0ab2f 100644 --- a/test/RestSharp.Tests.Integrated/RedirectOptionsTest.cs +++ b/test/RestSharp.Tests.Integrated/RedirectOptionsTest.cs @@ -1,11 +1,8 @@ using RestSharp.Tests.Integrated.Server; -using System; -using System.Collections.Generic; -using System.Linq; +using System.Net; using System.Net.Security; using System.Security.Cryptography.X509Certificates; -using System.Text; -using System.Threading.Tasks; +using RestSharp.Tests.Shared.Extensions; namespace RestSharp.Tests.Integrated { [Collection(nameof(TestServerCollection))] @@ -40,25 +37,68 @@ public async Task Can_RedirectForwardHeadersFalseWithAuthAndCookie_DropHeaders() request.AddQueryParameter("url", "/dump-headers"); var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.ResponseUri.Should().Be($"{_baseUri}dump-headers?url=%2fdump-headers"); + var content = response.Content; + content.Should() + .NotContain("'Accept':") + .And.NotContain("'User-Agent':") + // NOTE: This is expected to be there for normal HTTP purposes + // and is expected to be re-added by the underlying HttpClient: + .And.Contain("'Host': ") + // NOTE: options.AutomaticDecompression controls + // Accept-Encoding, so since we did nothing to change that + // the underlying HttpClient will re-add this header: + .And.Contain("'Accept-Encoding':") + // These are expected due to redirection options for this test: + .And.Contain("'Cookie':") + .And.Contain("'Authorization':"); + + // Verify the cookie exists from the redirected get: + response.Cookies!.Count.Should().BeGreaterThan(0).And.Be(1); + response.Cookies.Should().ContainCookieWithNameAndValue("redirectCookie", "value1"); + } + + [Fact] + public async Task Can_RedirectForwardHeadersFalseWithoutCookie_DropHeadersAndCookies() { + var options = NewOptions(); + options.RedirectOptions.ForwardHeaders = false; + options.RedirectOptions.ForwardCookies = false; + var client = new RestClient(options); + + // This request sets cookies and redirects to url param value + // if supplied, otherwise redirects to /get-cookies + var request = new RestRequest("/get-cookies-redirect") { + Method = Method.Get, + }; + request.AddHeader("Authorization", "blah"); + request.AddQueryParameter("url", "/dump-headers"); + + var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); response.ResponseUri.Should().Be($"{_baseUri}dump-headers?url=%2fdump-headers"); var content = response.Content; - content.Should().NotContain("'Accept':"); - content.Should().NotContain("'User-Agent':"); // NOTE: This is expected to be there for normal HTTP purposes // and is expected to be re-added by the underlying HttpClient: - content.Should().Contain("'Host': "); - // NOTE: options.AutomaticDecompression controls - // Accept-Encoding, so since we did nothing to change that - // the underlying HttpClient will re-add this header: - content.Should().Contain("'Accept-Encoding':"); - // These are expected due to redirection options for this test: - content.Should().Contain("'Cookie':"); - content.Should().Contain("'Authorization':"); + content.Should() + .Contain("'Host': ") + // NOTE: options.AutomaticDecompression controls + // Accept-Encoding, so since we did nothing to change that + // the underlying HttpClient will re-add this header: + .And.Contain("'Accept-Encoding':") + // These are expected due to redirection options for this test: + .And.NotContain("'Cookie':") + .And.NotContain("'Accept':") + .And.NotContain("'User-Agent':") + .And.NotContain("'Authorization':"); // Verify the cookie exists from the redirected get: - response.Cookies.Count.Should().BeGreaterThan(0).And.Be(1); - response.Cookies[0].Name.Should().Be("redirectCookie"); - response.Cookies[0].Value.Should().Be("value1"); + response.Cookies!.Count.Should().BeGreaterThan(0).And.Be(1); + // The cookies from get-cookies-redirect are placed in the cookie container + // even though they aren't transmitted to the server on the redirect to dump-headers: + response.Cookies.Should().ContainCookieWithNameAndValue("redirectCookie", "value1"); } [Fact] @@ -75,26 +115,39 @@ public async Task Can_RedirectForwardHeadersFalseWithCookie_DropHeaders() { request.AddHeader("Authorization", "blah"); request.AddQueryParameter("url", "/dump-headers"); + // These are required to make sure existing cookie headers are preserved + // for this test: + request.CookieContainer = new(); + request.CookieContainer.Add(new Cookie("cookie", "value", null, _host)); + request.CookieContainer.Add(new Cookie("cookie2", "value2", null, _host)); + var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); response.ResponseUri.Should().Be($"{_baseUri}dump-headers?url=%2fdump-headers"); var content = response.Content; - content.Should().NotContain("'Accept':"); - content.Should().NotContain("'User-Agent':"); - content.Should().NotContain("'Authorization':"); - // NOTE: This is expected to be there for normal HTTP purposes - // and is expected to be re-added by the underlying HttpClient: - content.Should().Contain("'Host': "); - // NOTE: options.AutomaticDecompression controls - // Accept-Encoding, so since we did nothing to change that - // the underlying HttpClient will re-add this header: - content.Should().Contain("'Accept-Encoding':"); - // These are expected due to redirection options for this test: - content.Should().Contain("'Cookie':"); + content.Should() + // These are expected due to redirection options for this test: + .Contain("'Cookie':") + .And.NotContain("'Accept':") + .And.NotContain("'User-Agent':") + .And.NotContain("'Authorization':") + // NOTE: This is expected to be there for normal HTTP purposes + // and is expected to be re-added by the underlying HttpClient: + .And.Contain("'Host': ") + // NOTE: options.AutomaticDecompression controls + // Accept-Encoding, so since we did nothing to change that + // the underlying HttpClient will re-add this header: + .And.Contain("'Accept-Encoding':"); // Verify the cookie exists from the redirected get: - response.Cookies.Count.Should().BeGreaterThan(0).And.Be(1); - response.Cookies[0].Name.Should().Be("redirectCookie"); - response.Cookies[0].Value.Should().Be("value1"); + response.Cookies!.Count.Should().BeGreaterThan(0).And.Be(3); + // The cookies from get-cookies-redirect are placed in the cookie container + // even though they aren't transmitted to the server on the redirect to dump-headers: + response.Cookies.Should() + .ContainCookieWithNameAndValue("redirectCookie", "value1") + .And.ContainCookieWithNameAndValue("cookie", "value") + .And.ContainCookieWithNameAndValue("cookie2", "value2"); } [Fact] @@ -113,25 +166,27 @@ public async Task Can_RedirectForwardHeadersFalseWithoutCookie_DropHeaders() { request.AddQueryParameter("url", "/dump-headers"); var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); response.ResponseUri.Should().Be($"{_baseUri}dump-headers?url=%2fdump-headers"); var content = response.Content; - content.Should().NotContain("'Accept':"); - content.Should().NotContain("'User-Agent':"); - content.Should().NotContain("'Authorization':"); - // This is expected due to redirection options for this test: - content.Should().NotContain("'Cookie':"); - // NOTE: This is expected to be there for normal HTTP purposes - // and is expected to be re-added by the underlying HttpClient: - content.Should().Contain("'Host':"); - // NOTE: options.AutomaticDecompression controls - // Accept-Encoding, so since we did nothing to change that - // the underlying HttpClient will re-add this header: - content.Should().Contain("'Accept-Encoding':"); + content.Should() + // This is expected due to redirection options for this test: + .NotContain("'Accept':") + .And.NotContain("'User-Agent':") + .And.NotContain("'Authorization':") + .And.NotContain("'Cookie':") + // NOTE: This is expected to be there for normal HTTP purposes + // and is expected to be re-added by the underlying HttpClient: + .And.Contain("'Host':") + // NOTE: options.AutomaticDecompression controls + // Accept-Encoding, so since we did nothing to change that + // the underlying HttpClient will re-add this header: + .And.Contain("'Accept-Encoding':"); // Verify the cookie exists from the redirected get: - response.Cookies.Count.Should().BeGreaterThan(0).And.Be(1); - response.Cookies[0].Name.Should().Be("redirectCookie"); - response.Cookies[0].Value.Should().Be("value1"); + response.Cookies!.Count.Should().BeGreaterThan(0).And.Be(1); + response.Cookies.Should().ContainCookieWithNameAndValue("redirectCookie", "value1"); } [Fact] @@ -150,24 +205,26 @@ public async Task Can_RedirectWithForwardCookieFalse() { request.AddQueryParameter("url", "/dump-headers"); var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); response.ResponseUri.Should().Be($"{_baseUri}dump-headers?url=%2fdump-headers"); var content = response.Content; // This is expected due to redirection options for this test: - content.Should().NotContain("'Cookie':"); - // These should exist: - content.Should().Contain("'Accept':"); - content.Should().Contain("'User-Agent':"); - content.Should().Contain("'Authorization':"); - content.Should().Contain("'Host':"); - content.Should().Contain("'Accept-Encoding':"); + content.Should() + .NotContain("'Cookie':") + // These should exist: + .And.Contain("'Accept':") + .And.Contain("'User-Agent':") + .And.Contain("'Authorization':") + .And.Contain("'Host':") + .And.Contain("'Accept-Encoding':"); // Regardless of ForwardCookie, the cookie container is ALWAYS // updated: // Verify the cookie exists from the redirected get: - response.Cookies.Count.Should().BeGreaterThan(0).And.Be(1); - response.Cookies[0].Name.Should().Be("redirectCookie"); - response.Cookies[0].Value.Should().Be("value1"); + response.Cookies!.Count.Should().BeGreaterThan(0).And.Be(1); + response.Cookies.Should().ContainCookieWithNameAndValue("redirectCookie", "value1"); } [Fact] @@ -183,20 +240,22 @@ public async Task Can_RedirectWithForwardQueryWithRedirectLocationContainingQuer request.AddQueryParameter("url", "/dump-headers?blah=blah2"); var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); response.ResponseUri.Should().Be($"{_baseUri}dump-headers?blah=blah2"); var content = response.Content; // This is expected due to redirection options for this test: - content.Should().Contain("'Cookie':"); - // These should exist: - content.Should().Contain("'Accept':"); - content.Should().Contain("'User-Agent':"); - content.Should().Contain("'Host':"); - content.Should().Contain("'Accept-Encoding':"); + content.Should() + .Contain("'Cookie':") + // These should exist: + .And.Contain("'Accept':") + .And.Contain("'User-Agent':") + .And.Contain("'Host':") + .And.Contain("'Accept-Encoding':"); // Verify the cookie exists from the redirected get: - response.Cookies.Count.Should().BeGreaterThan(0).And.Be(1); - response.Cookies[0].Name.Should().Be("redirectCookie"); - response.Cookies[0].Value.Should().Be("value1"); + response.Cookies!.Count.Should().BeGreaterThan(0).And.Be(1); + response.Cookies.Should().ContainCookieWithNameAndValue("redirectCookie", "value1"); } [Fact] @@ -213,20 +272,22 @@ public async Task Can_RedirectWithForwardQueryFalse() { request.AddQueryParameter("url", "/dump-headers"); var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); response.ResponseUri.Should().Be($"{_baseUri}dump-headers"); var content = response.Content; - // This is expected due to redirection options for this test: - content.Should().Contain("'Cookie':"); - // These should exist: - content.Should().Contain("'Accept':"); - content.Should().Contain("'User-Agent':"); - content.Should().Contain("'Host':"); - content.Should().Contain("'Accept-Encoding':"); + content.Should() + // This is expected due to redirection options for this test: + .Contain("'Cookie':") + // These should exist: + .And.Contain("'Accept':") + .And.Contain("'User-Agent':") + .And.Contain("'Host':") + .And.Contain("'Accept-Encoding':"); // Verify the cookie exists from the redirected get: - response.Cookies.Count.Should().BeGreaterThan(0).And.Be(1); - response.Cookies[0].Name.Should().Be("redirectCookie"); - response.Cookies[0].Value.Should().Be("value1"); + response.Cookies!.Count.Should().BeGreaterThan(0).And.Be(1); + response.Cookies.Should().ContainCookieWithNameAndValue("redirectCookie", "value1"); } [Fact] @@ -242,20 +303,22 @@ public async Task Can_RedirectWithForwardFragment() { request.AddQueryParameter("url", "/dump-headers"); var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); response.ResponseUri.Should().Be($"{_baseUri}dump-headers?url=%2fdump-headers#fragmentName"); var content = response.Content; - // This is expected due to redirection options for this test: - content.Should().Contain("'Cookie':"); - // These should exist: - content.Should().Contain("'Accept':"); - content.Should().Contain("'User-Agent':"); - content.Should().Contain("'Host':"); - content.Should().Contain("'Accept-Encoding':"); + content.Should() + // This is expected due to redirection options for this test: + .Contain("'Cookie':") + // These should exist: + .And.Contain("'Accept':") + .And.Contain("'User-Agent':") + .And.Contain("'Host':") + .And.Contain("'Accept-Encoding':"); // Verify the cookie exists from the redirected get: - response.Cookies.Count.Should().BeGreaterThan(0).And.Be(1); - response.Cookies[0].Name.Should().Be("redirectCookie"); - response.Cookies[0].Value.Should().Be("value1"); + response.Cookies!.Count.Should().BeGreaterThan(0).And.Be(1); + response.Cookies.Should().ContainCookieWithNameAndValue("redirectCookie", "value1"); } [Fact] @@ -272,6 +335,8 @@ public async Task Can_RedirectWithForwardFragmentFalse() { request.AddQueryParameter("url", "/dump-headers"); var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); response.ResponseUri.Should().Be($"{_baseUri}dump-headers?url=%2fdump-headers"); var content = response.Content; // This is expected due to redirection options for this test: @@ -283,9 +348,8 @@ public async Task Can_RedirectWithForwardFragmentFalse() { content.Should().Contain("'Accept-Encoding':"); // Verify the cookie exists from the redirected get: - response.Cookies.Count.Should().BeGreaterThan(0).And.Be(1); - response.Cookies[0].Name.Should().Be("redirectCookie"); - response.Cookies[0].Value.Should().Be("value1"); + response.Cookies!.Count.Should().BeGreaterThan(0).And.Be(1); + response.Cookies.Should().ContainCookieWithNameAndValue("redirectCookie", "value1"); } [Fact] @@ -300,14 +364,15 @@ public async Task Can_RedirectWithForwardFragmentWithoutQuery() { }; var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); response.ResponseUri.Should().Be($"{_baseUri}get-cookies#fragmentName"); var content = response.Content; content.Should().Contain("redirectCookie=value1"); // Verify the cookie exists from the redirected get: - response.Cookies.Count.Should().BeGreaterThan(0).And.Be(1); - response.Cookies[0].Name.Should().Be("redirectCookie"); - response.Cookies[0].Value.Should().Be("value1"); + response.Cookies!.Count.Should().BeGreaterThan(0).And.Be(1); + response.Cookies.Should().ContainCookieWithNameAndValue("redirectCookie", "value1"); } [Fact] @@ -316,17 +381,20 @@ public async Task Can_RedirectBelowMaxRedirects_WithLoweredValue() { options.RedirectOptions.MaxRedirects = 6; var client = new RestClient(options); - // This request sets cookies and redirects to url param value - // if supplied, otherwise redirects to /get-cookies + // This request issues redirections to itself subracting 1 + // from n until n == 1. var request = new RestRequest("/redirect-countdown") { Method = Method.Get, }; request.AddQueryParameter("n", "20"); var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.TemporaryRedirect); response.ResponseUri.Should().Be($"{_baseUri}redirect-countdown?n=15"); - HeaderParameter locationHeader = null; - response.Headers.Should().Contain((header) => string.Compare(header.Name, "Location", StringComparison.InvariantCultureIgnoreCase) == 0); + HeaderParameter? locationHeader = null; + response.Headers.Should() + .Contain((header) => string.Compare(header.Name, "Location", StringComparison.InvariantCultureIgnoreCase) == 0); locationHeader = (from header in response.Headers where string.Compare(header.Name, "Location", StringComparison.InvariantCultureIgnoreCase) == 0 select header).First(); @@ -340,14 +408,16 @@ public async Task Can_RedirectBelowMaxRedirects_WithDefault() { var options = NewOptions(); var client = new RestClient(options); - // This request sets cookies and redirects to url param value - // if supplied, otherwise redirects to /get-cookies + // This request issues redirections to itself subracting 1 + // from n until n == 1. var request = new RestRequest("/redirect-countdown") { Method = Method.Get, }; request.AddQueryParameter("n", "20"); var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); response.ResponseUri.Should().Be($"{_baseUri}redirect-countdown?n=1"); var content = response.Content; content.Should().Contain("Stopped redirection countdown!"); @@ -358,14 +428,16 @@ public async Task Can_RedirectAtMaxRedirects() { var options = NewOptions(); var client = new RestClient(options); - // This request sets cookies and redirects to url param value - // if supplied, otherwise redirects to /get-cookies + // This request issues redirections to itself subracting 1 + // from n until n == 1. var request = new RestRequest("/redirect-countdown") { Method = Method.Get, }; request.AddQueryParameter("n", "50"); var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); response.ResponseUri.Should().Be($"{_baseUri}redirect-countdown?n=1"); var content = response.Content; content.Should().Contain("Stopped redirection countdown!"); @@ -376,14 +448,16 @@ public async Task Can_StopRedirectAboveMaxRedirectDefault() { var options = NewOptions(); var client = new RestClient(options); - // This request sets cookies and redirects to url param value - // if supplied, otherwise redirects to /get-cookies + // This request issues redirections to itself subracting 1 + // from n until n == 1. var request = new RestRequest("/redirect-countdown") { Method = Method.Get, }; request.AddQueryParameter("n", "51"); var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.TemporaryRedirect); response.ResponseUri.Should().Be($"{_baseUri}redirect-countdown?n=2"); var content = response.Content; content.Should().NotContain("Stopped redirection countdown!"); @@ -395,22 +469,24 @@ public async Task Can_StopRedirectAboveMaxRedirectSet() { options.RedirectOptions.MaxRedirects = 5; var client = new RestClient(options); - // This request sets cookies and redirects to url param value - // if supplied, otherwise redirects to /get-cookies + // This request issues redirections to itself subracting 1 + // from n until n == 1. var request = new RestRequest("/redirect-countdown") { Method = Method.Get, }; request.AddQueryParameter("n", "6"); var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.TemporaryRedirect); response.ResponseUri.Should().Be($"{_baseUri}redirect-countdown?n=2"); var content = response.Content; content.Should().NotContain("Stopped redirection countdown!"); } // Custom logic that can either override or extends the .NET validation logic - private static bool RemoteCertificateValidationCallback(object sender, X509Certificate certificate, - X509Chain chain, + private static bool RemoteCertificateValidationCallback(object sender, X509Certificate? certificate, + X509Chain? chain, SslPolicyErrors sslPolicyErrors) { return true; } @@ -421,16 +497,18 @@ public async Task Can_FailToRedirectToInsecureUrl() { options.RemoteCertificateValidationCallback = RemoteCertificateValidationCallback; var client = new RestClient(options); - // This request sets cookies and redirects to url param value - // if supplied, otherwise redirects to /get-cookies + // This request redirects to insecure /dump-headers + // if the redirection is allowed. var request = new RestRequest($"{_baseSecureUri}redirect-insecure") { Method = Method.Get, }; var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.TemporaryRedirect); response.ResponseUri.Should().NotBe($"{_baseUri}dump-headers"); response.ResponseUri.Should().Be($"{_baseSecureUri}redirect-insecure"); - HeaderParameter locationHeader = null; + HeaderParameter? locationHeader = null; response.Headers.Should().Contain((header) => string.Compare(header.Name, "Location", StringComparison.InvariantCultureIgnoreCase) == 0); locationHeader = (from header in response.Headers where string.Compare(header.Name, "Location", StringComparison.InvariantCultureIgnoreCase) == 0 @@ -445,13 +523,15 @@ public async Task Can_RedirectToInsecureUrlWithRedirectOption_True() { options.RedirectOptions.FollowRedirectsToInsecure = true; var client = new RestClient(options); - // This request sets cookies and redirects to url param value - // if supplied, otherwise redirects to /get-cookies + // This request redirects to insecure /dump-headers + // if the redirection is allowed. var request = new RestRequest($"{_baseSecureUri}redirect-insecure") { Method = Method.Get, }; var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); response.ResponseUri.Should().Be($"{_baseUri}dump-headers"); response.ResponseUri.Should().NotBe($"{_baseSecureUri}redirect-insecure"); } @@ -462,20 +542,254 @@ public async Task Can_RedirectToSecureUrl() { options.RemoteCertificateValidationCallback = RemoteCertificateValidationCallback; var client = new RestClient(options); - // This request sets cookies and redirects to url param value - // if supplied, otherwise redirects to /get-cookies + // This request redirects to secure /dump-headers + // if the redirection is allowed. var request = new RestRequest($"{_baseUri}redirect-secure") { Method = Method.Get, }; var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); response.ResponseUri.Should().Be($"{_baseSecureUri}dump-headers"); response.ResponseUri.Should().NotBe($"{_baseUri}redirect-insecure"); var content = response.Content; - content.Should().Contain("'Accept':"); - content.Should().Contain("'User-Agent':"); - content.Should().Contain("'Host':"); - content.Should().Contain("'Accept-Encoding':"); + content.Should() + .Contain("'Accept':") + .And.Contain("'User-Agent':") + .And.Contain("'Host':") + .And.Contain("'Accept-Encoding':"); + } + + [Fact] + public async Task Can_NotFollowRedirect_WithRedirectOption_FollowRedirect_False() { + var options = NewOptions(); + options.RedirectOptions.FollowRedirects = false; + var client = new RestClient(options); + + // This request issues redirections to itself subracting 1 + // from n until n == 1. + var request = new RestRequest($"{_baseUri}redirect-countdown") { + Method = Method.Get, + }; + request.AddQueryParameter("n", "17"); + var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.TemporaryRedirect); + response.ResponseUri.Should().Be($"{_baseUri}redirect-countdown?n=17"); + HeaderParameter? locationHeader = null; + response.Headers.Should().Contain((header) => string.Compare(header.Name, "Location", StringComparison.InvariantCultureIgnoreCase) == 0); + locationHeader = (from header in response.Headers + where string.Compare(header.Name, "Location", StringComparison.InvariantCultureIgnoreCase) == 0 + select header).First(); + locationHeader.Value.Should().Be("/redirect-countdown?n=16"); + } + + [Fact] + public async Task Can_NotFollowRedirect_WithOption_FollowRedirect_False() { + var options = NewOptions(); + options.FollowRedirects = false; + var client = new RestClient(options); + + // This request issues redirections to itself subracting 1 + // from n until n == 1. + var request = new RestRequest($"{_baseUri}redirect-countdown") { + Method = Method.Get, + }; + request.AddQueryParameter("n", "17"); + var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.TemporaryRedirect); + response.ResponseUri.Should().Be($"{_baseUri}redirect-countdown?n=17"); + HeaderParameter? locationHeader = null; + response.Headers.Should().Contain((header) => string.Compare(header.Name, "Location", StringComparison.InvariantCultureIgnoreCase) == 0); + locationHeader = (from header in response.Headers + where string.Compare(header.Name, "Location", StringComparison.InvariantCultureIgnoreCase) == 0 + select header).First(); + locationHeader.Value.Should().Be("/redirect-countdown?n=16"); + } + + [Fact] + public async Task Can_NotAlterVerb_WithRedirectOption_AllowForcedRedirectVerbChange_False_WithStatusCode_302() { + var options = NewOptions(); + // NOTE: This isn't required, it just makes the test simpler: + options.RedirectOptions.ForwardQuery = false; + // This is the setting for the test: + options.RedirectOptions.AllowForcedRedirectVerbChange = false; + var client = new RestClient(options); + + // This request issues redirections to the url parameter or /dump-headers + // with a 302 status code. + var request = new RestRequest($"{_baseUri}redirect-forcechangeverb") { + Method = Method.Post, + }; + request.AddQueryParameter("url", $"{_baseUri}dump-request"); + request.AddStringBody("blah blah blah", DataFormat.None); + var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.ResponseUri.Should().Be($"{_baseUri}dump-request"); + var content = response.Content; + content.Should().Contain("POST") + .And.Contain("blah blah blah"); + } + + [Fact] + public async Task Can_NotAlterVerb_WithRedirectOption_AllowRedirectMethodStatusCodeToAlterVerb_WithStatusCode_303() { + var options = NewOptions(); + // NOTE: This isn't required, it just makes the test simpler: + options.RedirectOptions.ForwardQuery = false; + // This is the setting for the test: + options.RedirectOptions.AllowRedirectMethodStatusCodeToAlterVerb = false; + var client = new RestClient(options); + // This request issues redirections to the url parameter or /dump-headers + // with a 303 status code. + var request = new RestRequest($"{_baseUri}redirect-changeverb") { + Method = Method.Post, + }; + request.AddQueryParameter("url", $"{_baseUri}dump-request"); + request.AddStringBody("blah blah blah", DataFormat.None); + var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.ResponseUri.Should().Be($"{_baseUri}dump-request"); + var content = response.Content; + content.Should().Contain("POST") + .And.Contain("blah blah blah"); + } + + [Fact] + public async Task Can_AlterVerb_WithStatusCode302() { + var options = NewOptions(); + // NOTE: This isn't required, it just makes the test simpler: + options.RedirectOptions.ForwardQuery = false; + var client = new RestClient(options); + // This request issues redirections to the url parameter or /dump-headers + // with a 302 status code. + var request = new RestRequest($"{_baseUri}redirect-forcechangeverb") { + Method = Method.Post, + }; + request.AddQueryParameter("url", $"{_baseUri}dump-request"); + request.AddStringBody("blah blah blah", DataFormat.None); + var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.ResponseUri.Should().Be($"{_baseUri}dump-request"); + var content = response.Content; + content.Should().Contain("GET") + .And.NotContain("blah blah blah", "Altered verbs MUST NOT foward along the body"); + } + + [Fact] + public async Task Can_AlterVerb_WithStatusCode_303() { + var options = NewOptions(); + // NOTE: This isn't required, it just makes the test simpler: + options.RedirectOptions.ForwardQuery = false; + var client = new RestClient(options); + // This request issues redirections to the url parameter or /dump-headers + // with a 303 status code. + var request = new RestRequest($"{_baseUri}redirect-changeverb") { + Method = Method.Post, + }; + request.AddQueryParameter("url", $"{_baseUri}dump-request"); + request.AddStringBody("blah blah blah", DataFormat.None); + var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.ResponseUri.Should().Be($"{_baseUri}dump-request"); + var content = response.Content; + content.Should().Contain("GET") + .And.NotContain("blah blah blah", "Altered verbs MUST NOT foward along the body"); + } + + [Fact] + public async Task Can_RedirectWithoutChangingVerb_With_RedirectStatus_307() { + var options = NewOptions(); + // NOTE: This isn't required, it just makes the test simpler: + options.RedirectOptions.ForwardQuery = false; + var client = new RestClient(options); + // This request issues redirections to the url parameter or /dump-headers + // with a 307 status code. + var request = new RestRequest($"{_baseUri}redirect-keepverb") { + Method = Method.Post, + }; + request.AddQueryParameter("url", $"{_baseUri}dump-request"); + request.AddStringBody("blah blah blah", DataFormat.None); + var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.ResponseUri.Should().Be($"{_baseUri}dump-request"); + var content = response.Content; + content.Should().Contain("POST") + .And.Contain("blah blah blah", "Altered verbs MUST NOT foward along the body"); + } + + [Fact] + public async Task Can_RedirectWithCookies_HavingOptionLevel_CookieContainer() { + var options = NewOptions(); + // NOTE: This isn't required, it just makes the test simpler: + options.RedirectOptions.ForwardQuery = false; + options.CookieContainer = new (); + var client = new RestClient(options); + // This request issues redirections to the url parameter or /dump-headers + // with a 307 status code. + var request = new RestRequest($"{_baseUri}get-cookies-redirect") { + Method = Method.Get, + }; + request.AddQueryParameter("url", $"{_baseUri}set-cookies"); + var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.ResponseUri.Should().Be($"{_baseUri}set-cookies"); + response.Cookies!.Count.Should().Be(5); + response.Cookies.Should() + .ContainCookieWithNameAndValue("redirectCookie", "value1") + .And.ContainCookieWithNameAndValue("cookie1", "value1") + // This cookie is excluded from the response CookieCollection because /path_extra + // doesn't intersect with the ResponseUri. + .And.NotContainCookieWithNameAndValue("cookie2", "value2") + .And.ContainCookieWithNameAndValue("cookie3", "value3") + .And.ContainCookieWithNameAndValue("cookie4", "value4") + // This cookie is excluded from the response CookieCollection because + // it was marked as secure. + .And.NotContainCookieWithNameAndValue("cookie5", "value5") + .And.ContainCookieWithNameAndValue("cookie6", "value6"); + verifyAllCookies(request.CookieContainer!.GetAllCookies()); + verifyAllCookies(options.CookieContainer!.GetAllCookies()); + + void verifyAllCookies(CookieCollection cookies) { + cookies.Should() + .ContainCookieWithNameAndValue("redirectCookie", "value1") + .And.ContainCookieWithNameAndValue("cookie1", "value1") + .And.ContainCookieWithNameAndValue("cookie2", "value2") + .And.ContainCookieWithNameAndValue("cookie3", "value3") + .And.ContainCookieWithNameAndValue("cookie4", "value4") + .And.ContainCookieWithNameAndValue("cookie5", "value5") + .And.ContainCookieWithNameAndValue("cookie6", "value6"); + } + } + + [Fact] + public async Task Can_AlterVerb_WithRedirectStatusCode_303_AndForwardBody() { + var options = NewOptions(); + // NOTE: This isn't required, it just makes the test simpler: + options.RedirectOptions.ForwardQuery = false; + options.RedirectOptions.ForceForwardBody = true; + var client = new RestClient(options); + // This request issues redirections to the url parameter or /dump-headers + // with a 303 status code. + var request = new RestRequest($"{_baseUri}redirect-changeverb") { + Method = Method.Post, + }; + request.AddQueryParameter("url", $"{_baseUri}dump-request"); + request.AddStringBody("blah blah blah", DataFormat.None); + var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.ResponseUri.Should().Be($"{_baseUri}dump-request"); + var content = response.Content; + content.Should().Contain("GET") + .And.Contain("blah blah blah", "ForwardBody"); } } } diff --git a/test/RestSharp.Tests.Integrated/RedirectTests.cs b/test/RestSharp.Tests.Integrated/RedirectTests.cs index a149c60d9..51a02c7f1 100644 --- a/test/RestSharp.Tests.Integrated/RedirectTests.cs +++ b/test/RestSharp.Tests.Integrated/RedirectTests.cs @@ -14,8 +14,8 @@ // using System.Net; -using RestSharp.Extensions; using RestSharp.Tests.Integrated.Server; +using RestSharp.Tests.Shared.Extensions; namespace RestSharp.Tests.Integrated; @@ -48,10 +48,22 @@ public async Task Can_Perform_GET_Async_With_Request_Cookies_And_RedirectCookie( var request = new RestRequest("get-cookies-redirect") { CookieContainer = new CookieContainer(), }; + request.AddQueryParameter("url", "set-cookies"); request.CookieContainer.Add(new Cookie("cookie", "value", null, _host)); request.CookieContainer.Add(new Cookie("cookie2", "value2", null, _host)); var response = await _client.ExecuteAsync(request); - response.Content.Should().Be("[\"redirectCookie=value1\",\"cookie=value\",\"cookie2=value2\"]"); + response.Content.Should().Contain("success"); + request.CookieContainer!.Count.Should().Be(9); + request.CookieContainer!.GetAllCookies().Should() + .ContainCookieWithNameAndValue("cookie", "value") + .And.ContainCookieWithNameAndValue("cookie2", "value2") + .And.ContainCookieWithNameAndValue("redirectCookie", "value1") + .And.ContainCookieWithNameAndValue("cookie1", "value1") + .And.ContainCookieWithNameAndValue("cookie2", "value2") + .And.ContainCookieWithNameAndValue("cookie3", "value3") + .And.ContainCookieWithNameAndValue("cookie4", "value4") + .And.ContainCookieWithNameAndValue("cookie5", "value5") + .And.ContainCookieWithNameAndValue("cookie6", "value6"); } [Fact] @@ -61,10 +73,10 @@ public async Task Can_Perform_POST_Async_With_RedirectionResponse_Cookies() { }; var response = await _client.ExecuteAsync(request); + // Verify the cookie exists from the POST: - response.Cookies.Count.Should().BeGreaterThan(0).And.Be(1); - response.Cookies[0].Name.Should().Be("redirectCookie"); - response.Cookies[0].Value.Should().Be("value1"); + response.Cookies!.Count.Should().BeGreaterThan(0).And.Be(1); + response.Cookies.Should().ContainCookieWithNameAndValue("redirectCookie", "value1"); // Make sure the redirected location spits out the correct content: response.Content.Should().Be("[\"redirectCookie=value1\"]", "was successfully redirected to get-cookies"); } @@ -76,10 +88,10 @@ public async Task Can_Perform_POST_Async_With_SeeOtherRedirectionResponse_Cookie }; var response = await _client.ExecuteAsync(request); + // Verify the cookie exists from the POST: - response.Cookies.Count.Should().BeGreaterThan(0).And.Be(1); - response.Cookies[0].Name.Should().Be("redirectCookie"); - response.Cookies[0].Value.Should().Be("seeOtherValue1"); + response.Cookies!.Count.Should().BeGreaterThan(0).And.Be(1); + response.Cookies.Should().ContainCookieWithNameAndValue("redirectCookie", "seeOtherValue1"); // Make sure the redirected location spits out the correct content: response.Content.Should().Be("[\"redirectCookie=seeOtherValue1\"]", "was successfully redirected to get-cookies"); } @@ -91,12 +103,11 @@ public async Task Can_Perform_PUT_Async_With_RedirectionResponse_Cookies() { }; var response = await _client.ExecuteAsync(request); + // Verify the cookie exists from the PUT: - response.Cookies.Count.Should().BeGreaterThan(0).And.Be(1); - response.Cookies[0].Name.Should().Be("redirectCookie"); - response.Cookies[0].Value.Should().Be("putCookieValue1"); - // However, the redirection location should have been a 404: - // Make sure the redirected location spits out the correct content from PUT /get-cookies: + response.Cookies!.Count.Should().BeGreaterThan(0).And.Be(1); + response.Cookies.Should().ContainCookieWithNameAndValue("redirectCookie", "putCookieValue1"); + // However, the redirection status code should be a 405 (Method Not Allowed): response.StatusCode.Should().Be(HttpStatusCode.MethodNotAllowed); } @@ -112,26 +123,23 @@ public async Task Can_ForwardHeadersTrue_OnRedirect() { var response = await _client.ExecuteAsync(request); response.ResponseUri.Should().Be($"{_client.Options.BaseUrl}dump-headers?url=%2fdump-headers"); var content = response.Content; - content.Should().Contain("'Accept':"); - content.Should().Contain($"'Host': {_client.Options.BaseHost}"); - content.Should().Contain("'User-Agent':"); - content.Should().Contain("'Accept-Encoding':"); - content.Should().Contain("'Cookie':"); + content.Should() + .Contain("'Accept':") + .And.Contain($"'Host': {_client.Options.BaseHost}") + .And.Contain("'User-Agent':") + .And.Contain("'Accept-Encoding':") + .And.Contain("'Cookie':"); // Verify the cookie exists from the redirected get: - response.Cookies.Count.Should().BeGreaterThan(0).And.Be(1); - response.Cookies[0].Name.Should().Be("redirectCookie"); - response.Cookies[0].Value.Should().Be("value1"); + response.Cookies!.Count.Should().BeGreaterThan(0).And.Be(1); + response.Cookies.Should().ContainCookieWithNameAndValue("redirectCookie", "value1"); } // Needed tests: //Test: ForwardBody = true (default, might not need test) //Test: ForwardBody = false //Test: ForceForwardBody = false (default, might not need test) - //Test: AllowRedirectMethodStatusCodeToAlterVerb = true (default, might not need test) - //Test: AllowRedirectMethodStatusCodeToAlterVerb = false //Test: Altered Redirect Status Codes list - //Test: FollowRedirects = false class Response { diff --git a/test/RestSharp.Tests.Integrated/RestSharp.Tests.Integrated.csproj b/test/RestSharp.Tests.Integrated/RestSharp.Tests.Integrated.csproj index 4974fd7b6..22ca7a974 100644 --- a/test/RestSharp.Tests.Integrated/RestSharp.Tests.Integrated.csproj +++ b/test/RestSharp.Tests.Integrated/RestSharp.Tests.Integrated.csproj @@ -20,9 +20,9 @@ - - - + + + diff --git a/test/RestSharp.Tests.Integrated/Server/TestServer.cs b/test/RestSharp.Tests.Integrated/Server/TestServer.cs index 6be0ccf2b..495e2ef4d 100644 --- a/test/RestSharp.Tests.Integrated/Server/TestServer.cs +++ b/test/RestSharp.Tests.Integrated/Server/TestServer.cs @@ -7,6 +7,7 @@ using RestSharp.Tests.Shared.Extensions; using System.Net; using System.Reflection; +using System.Security; using System.Security.Cryptography.X509Certificates; using System.Text; using System.Web; @@ -33,7 +34,18 @@ public HttpServer(ITestOutputHelper? output = null) { builder.Services.AddControllers().AddApplicationPart(typeof(UploadController).Assembly); builder.WebHost.UseUrls(Address, SecureAddress).UseKestrel(options => { options.ListenAnyIP(5151, listenOptions => { return; }); - options.ListenAnyIP(5152, listenOptions => listenOptions.UseHttps(new X509Certificate2(Path.Join(currentAssemblyPath, "Server\\testCert.pfx"), string.Empty))); + // Yes, this is lame, but dotnet dev-certs was giving me grief trying to export + // the public key using an empty password... :( + var secureString = new SecureString(); + secureString.AppendChar('b'); + secureString.AppendChar('l'); + secureString.AppendChar('a'); + secureString.AppendChar('h'); + secureString.MakeReadOnly(); + options.ListenAnyIP(5152, + listenOptions => listenOptions.UseHttps( + new X509Certificate2(Path.Join(currentAssemblyPath, "Server\\testCert.pfx"), + secureString))); }); _app = builder.Build(); @@ -47,6 +59,41 @@ public HttpServer(ITestOutputHelper? output = null) { _app.MapGet("headers", HeaderHandlers.HandleHeaders); _app.MapGet("request-echo", async context => await context.Request.BodyReader.AsStream().CopyToAsync(context.Response.BodyWriter.AsStream())); _app.MapDelete("delete", () => new TestResponse { Message = "Works!" }); + _app.MapPost("redirect-forcechangeverb", + (HttpContext ctx) => { + string redirectDestination = "/dump-headers"; + var queryString = HttpUtility.ParseQueryString(ctx.Request.QueryString.Value ?? string.Empty); + var urlParameter = queryString.Get("url"); + if (!string.IsNullOrEmpty(urlParameter)) { + redirectDestination = urlParameter; + } + // This forces the verb to change on the redirect to GET, unless the client has set the correct + // RedirectOption setting. (302) + return new RedirectWithStatusCodeResult((int)HttpStatusCode.Redirect, redirectDestination); + }); + _app.MapPost("redirect-changeverb", + (HttpContext ctx) => { + string redirectDestination = "/dump-headers"; + var queryString = HttpUtility.ParseQueryString(ctx.Request.QueryString.Value ?? string.Empty); + var urlParameter = queryString.Get("url"); + if (!string.IsNullOrEmpty(urlParameter)) { + redirectDestination = urlParameter; + } + // This allows the method to change to GET on redirect on purpose, unless the client has set the correct + // RedirectOption setting. (303) + return new RedirectWithStatusCodeResult((int)HttpStatusCode.RedirectMethod, redirectDestination); + }); + _app.MapPost("redirect-keepverb", + (HttpContext ctx) => { + string redirectDestination = "/dump-headers"; + var queryString = HttpUtility.ParseQueryString(ctx.Request.QueryString.Value ?? string.Empty); + var urlParameter = queryString.Get("url"); + if (!string.IsNullOrEmpty(urlParameter)) { + redirectDestination = urlParameter; + } + // This prevents the method to change on redirect. (307) + return new RedirectWithStatusCodeResult((int)HttpStatusCode.RedirectKeepVerb, redirectDestination); + }); _app.MapGet("redirect-insecure", (HttpContext ctx) => { string destination = $"{Address}/dump-headers"; return Results.Redirect(destination, false, true); @@ -58,7 +105,7 @@ public HttpServer(ITestOutputHelper? output = null) { _app.MapGet("redirect-countdown", (HttpContext ctx) => { string redirectDestination = "/redirect-countdown"; - var queryString = HttpUtility.ParseQueryString(ctx.Request.QueryString.Value); + var queryString = HttpUtility.ParseQueryString(ctx.Request.QueryString.Value ?? string.Empty); int redirectsLeft = -1; redirectsLeft = int.Parse(queryString.Get("n")); if (redirectsLeft != -1 @@ -68,6 +115,7 @@ public HttpServer(ITestOutputHelper? output = null) { } return Results.Ok("Stopped redirection countdown!"); }); + _app.MapGet("dump-headers", (HttpContext ctx) => { var headers = ctx.Request.Headers; @@ -78,6 +126,11 @@ public HttpServer(ITestOutputHelper? output = null) { return new TestResponse { Message = sb.ToString() }; }); + _app.MapGet("dump-request", DumpRequest); + _app.MapPut("dump-request", DumpRequest); + _app.MapPost("dump-request", DumpRequest); + _app.MapDelete("dump-request", DumpRequest); + // Cookies _app.MapGet("get-cookies", CookieHandlers.HandleCookies); _app.MapPut("get-cookies", @@ -92,7 +145,7 @@ public HttpServer(ITestOutputHelper? output = null) { (HttpContext ctx) => { ctx.Response.Cookies.Append("redirectCookie", "value1"); string redirectDestination = "/get-cookies"; - var queryString = HttpUtility.ParseQueryString(ctx.Request.QueryString.Value); + var queryString = HttpUtility.ParseQueryString(ctx.Request.QueryString.Value ?? string.Empty); var urlParameter = queryString.Get("url"); if (!string.IsNullOrEmpty(urlParameter)) { redirectDestination = urlParameter; @@ -142,6 +195,15 @@ public HttpServer(ITestOutputHelper? output = null) { ); _app.MapPost("/post/data", FormRequestHandler.HandleForm); + + // Dump the request verb and body into the response. + TestResponse DumpRequest(HttpContext ctx) { + var method = ctx.Request.Method; + var task = ctx.Request.Body.StreamToStringAsync(); + task.Wait(); + var body = task.Result; + return new TestResponse { Message = $"Method: {method}\r\nBody: {body}" }; + } } public Uri Url => new(Address); diff --git a/test/RestSharp.Tests.Integrated/Server/testCert.pfx b/test/RestSharp.Tests.Integrated/Server/testCert.pfx index 0b9980d3ceebb4880b8b9d68cff78f819225b83e..bb6e3581267d172d81e1e5e84302227cf7490399 100644 GIT binary patch literal 2660 zcmZXUXH?VK7RCP|Apt^{BE5r@U;;wuCDhPSKzdU^kqA=dAl_BS}o2bRaO2#3Tr1luIy8IA8(6 zKt&`b76^%nk%sA!B>JuYvFPU@B>HI@9;ekX6#n-VGZToehy>X}k|1kHK`7IIF>N_3 zgkdD^lhVsq%0_f_ut5k3(i?5WU9j=p1=p4({g(@i;INU!S?1JC^q`+|_r*@~L7V{= zE#G)egC((hP*j+2LLak<%6fOaw{)&k+Y#Rs_U@$?r=HrfH3^j z3UN|LEPE43*=SXP2s&BggvTlqDpxGf)gR5@eReL{*jKxeP@|f*T)a?}*jN>lo?Ad)3XuPL`09(yY?yeJS{_*WzQA({kv1euDGP7f|f~ar5?kA z)s=J&<;)BHF7TC^g694oVQ5Y5dJju9qw~=l4L3}4h9@rE-(HR6a8VR5HZoSUyHKk9 zenus+;Lm)R>!>cbCcBWMq>@CXeZQ|o2RY=fCl9XB9y`v~U+)~(TzH>UzAC6PR>u*HpfPY#>$R+J$T^O)Ung|B_9@xwmxntrh1;tdecw<1oJofRx1@i^Mz~HgXH^d73NHy{R*R6gZKOofb@HEM zoo|XyOrzTTH0`&HLY`1gToVxYguV!u+{u_jp2Su0K2t=SBJLoI-CHN*t-OY`iP@+q zL#qXSTmX6eOg(zwG;gl@R!P~X*VUA)+q%84Syt2*1Qk-e8CZn08F<;eZ;ogYHxbpo zR^A&L=UC(M`;44~`cx_*!CZadR^mz|Sh4o(O#0a%zOWegaxoC1^hLUjKDzC$otIR9 zL)a*&K_*KzsPJWm8Y_h24)v5)zZ$9|6Z`nE)QdfzG4Hfl?m>iDS-M=BVZZi{lz%9k z;#dU!=;3y)>w~rF>}ksF76bjGsT0v??8{^NJv~-7hsRBI5|bg{xXvZAj51YP%>IDh z{cOni;km(C$C$iVkDmSgurt5BKjp}lcT3kk zM^>ufxO45sm!lD13O?DJr(7|=y;(VBo)8;Oy||yL%^%9PPepIqcmL>p zp?8|wv;@4LIQI0Y&|bVMq5jLHN;t(-Y~wT6T@>?F#x$zQ;If-9)3;@5-dI%3d*rEq z;*pUX1%*IB001Ce{)Jp9D`5*D5-j z&|V;Do&kWQaTVGW>Q`3>(6loe=>}z#hk_s=5}E^qvJtidEP<=QB_J3G1VU)#3xooq zz=gk6IN(Kdd}zd+)(O9yP?{G^<9@%m&96y3ZO3KWJ(!l@Z2$Zo2Bn2MfFyyB=|De^ z{eKb!#4X{2|}(Sd}2Y(I{V`B>h?Hh zq_4{;$1Y1*-YzY9-XT($Kd~YC#TqmFIGsnpks-mag?wggORZ!0h(E&N4}aV`Yiz~g zQ=SX>N|lu$GIq%z4s$L!T`SjuBagM%8yRw;I^iI^`3yF5l#|M5# zA#3k<}qP@o`3{uwyJ6Lg~IIi z(9y+-rGYqvZTG6W3ovIUSS^7++=K}R@=TcJNde60^#+B?0)B}ZT zNm*7_31`t;3%hntGS*KeAq;|@4)Y*H^<3G*uG3e&WWvXk9$b&aG*+3(bU%o=H87G; z9=mwbC%A0f#kdI{zPIH=#U5N!%qXY!W2N=_zQHncN=-z+p(l!Bh@I0^} zmJC0PsnCyRqCYSm;t-ua|1Rx4J;AufGF>xeot)jFWA?iETSpUT@P1QkmPwJxsmbko z8NC+~D{=*Xg4`v9A!qB+TrubOv)|e-lomdNFaJ=C$z@+@|B`HL%`n}O718#|;o&2( zpq8c^6Yb&QU~ose>Uj@J9DOaPLjja?^_sM!THm}joQ*yEoC7PmTcG_fgSRP zy8c{+%jggJqjM@!0mN4bm#XSWwv*6s0Eu~X QYubO`TCu=~<7W{32Phh@hyVZp literal 785 zcmXqLV&*kyV*I**nTe5!iIbsa!FzL0*C1&FUN%mxHjlRNyo`+8tPBPshC&7cY|No7 z%)*>G`N@en8TrK}26E!OM#cul#s-EKh870KQ9!N<5*LTACPpP>;~7~Qn41{+8Gz2< zVrpV!WVliG%~QZ$cYkbs)6*RF#06Hz_ONX~efz&@mPLKk&ON2IAxrKb%@&C)2{L`1 z_QLYuDfx@jZ!fA)=G^x2SyA20h;`aiRPU*C3JI<>?cBFXN7(I1n@ZoR>2vn$-`UFQ z>(Z{`^dbIUc|v}yZB~7sQ`hQRr`ywBEl%2}czntkd6C!qrw15pUUMZ__*~BE*#G`M zlC#g~ewvujcGI#bL}r)o@eNg_&rT->Tkd^%H(7f5(+|$U%dV+BG|zGJ{qjM&I8~$| zW4qF)k6S(LM4Nb~^ys^0v`1HLss8`wImdUW>${hRNr=R!?)mg?xAJGf@0ZTKpT)PV z=~sP3*2&IS(USMBUyRtv#LURRxH!om!GH%C;XafKsGa874#05d(yawD&NYTa5#syTw!q{XU!U&|8kYfQD$-r1( zWDxgtF1vMl;_gZ8-&k||ij2jNhRHQN>GC_WhACpoX^xE_zOntRU$Fjs*&WF$UNx^@ zi5-(~@f_UG`PF~g?elx4SGonN_wTrKRH#+p-v5m`!VZjI8txQ+o@cIbd0)?=0^wWQ zOAc>aHqGNt_xJl}4|v%~&wr3;w^~sufBCI-pKhNi?iAu%UG}nj)$5=8{@ygr4wzkj zF!_s0r_%LwjqH!#da@rKFfh6$x>F~?Y0ew@u+RI?o|fR(5^*a%xWee0{Gm-}T{#4J z&R(rwap>|Ln*&FRLIBQMJR1N2 diff --git a/test/RestSharp.Tests.Serializers.Csv/RestSharp.Tests.Serializers.Csv.csproj b/test/RestSharp.Tests.Serializers.Csv/RestSharp.Tests.Serializers.Csv.csproj index ebe55a837..7cda432ff 100644 --- a/test/RestSharp.Tests.Serializers.Csv/RestSharp.Tests.Serializers.Csv.csproj +++ b/test/RestSharp.Tests.Serializers.Csv/RestSharp.Tests.Serializers.Csv.csproj @@ -4,8 +4,8 @@ - - - + + + diff --git a/test/RestSharp.Tests.Serializers.Json/RestSharp.Tests.Serializers.Json.csproj b/test/RestSharp.Tests.Serializers.Json/RestSharp.Tests.Serializers.Json.csproj index eac6a444d..f1b45e9b1 100644 --- a/test/RestSharp.Tests.Serializers.Json/RestSharp.Tests.Serializers.Json.csproj +++ b/test/RestSharp.Tests.Serializers.Json/RestSharp.Tests.Serializers.Json.csproj @@ -16,8 +16,8 @@ - - - + + + diff --git a/test/RestSharp.Tests.Serializers.Xml/RestSharp.Tests.Serializers.Xml.csproj b/test/RestSharp.Tests.Serializers.Xml/RestSharp.Tests.Serializers.Xml.csproj index 5a6518bd9..2625d739f 100644 --- a/test/RestSharp.Tests.Serializers.Xml/RestSharp.Tests.Serializers.Xml.csproj +++ b/test/RestSharp.Tests.Serializers.Xml/RestSharp.Tests.Serializers.Xml.csproj @@ -24,9 +24,9 @@ - - - + + + diff --git a/test/RestSharp.Tests.Shared/Extensions/FluentAssertionCookieExtensions.cs b/test/RestSharp.Tests.Shared/Extensions/FluentAssertionCookieExtensions.cs new file mode 100644 index 000000000..b326045ac --- /dev/null +++ b/test/RestSharp.Tests.Shared/Extensions/FluentAssertionCookieExtensions.cs @@ -0,0 +1,104 @@ +using FluentAssertions.Collections; +using FluentAssertions.Execution; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Text; +using System.Threading.Tasks; +// Since the generic types are so long, lets simplify them: +using CookieCollection = FluentAssertions.Collections.GenericCollectionAssertions; +using AndWhich = FluentAssertions.AndWhichConstraint< + FluentAssertions.Collections.GenericCollectionAssertions, System.Net.Cookie>; + +namespace RestSharp.Tests.Shared.Extensions; + +/// +/// Some Fluent Assertion helper extensions for verifying CookieCollection contents. +/// +public static class FluentAssertionCookieExtensions { + /// + /// Allow FluentAssertions to be able to easily verify name/value cookies exist. + /// + /// + /// + /// + /// + /// + /// + static public AndWhich ContainCookieWithNameAndValue(this CookieCollection genericCollection, string name, string value, string because = "", params object[] becauseArgs) { + bool success = Execute.Assertion + .BecauseOf(because, becauseArgs) + .ForCondition(genericCollection.Subject is not null) + .FailWith("Expected Cookie {context:collection} to contain Name: '{0}', Value: '{1}'{reason}, but found .", name, value); + + IEnumerable matches = Enumerable.Empty(); + + if (success) { + IEnumerable collection = genericCollection.Subject; + + Execute.Assertion + .BecauseOf(because, becauseArgs) + .ForCondition(ContainsCookieWithNameAndValue(collection, name, value)) + .FailWith("Expected Cookie {context:collection} {0} to contain cookie with Name: '{1}' Value: '{2}'{reason}.", collection, name, value); + + matches = collection.Where(item => ContainsCookieWithNameAndValue(collection, name, value)); + } + + return new AndWhich(genericCollection, matches); + } + + /// + /// Allow FluentAssertions to be able to easily verify that the supplied name/value cookie does NOT exist. + /// + /// + /// + /// + /// + /// + /// + static public AndWhich NotContainCookieWithNameAndValue(this CookieCollection genericCollection, string name, string value, string because = "", params object[] becauseArgs) { + bool success = Execute.Assertion + .BecauseOf(because, becauseArgs) + .ForCondition(genericCollection.Subject is not null) + .FailWith("Expected {context:collection} to not contain Name: '{0}', Value: '{1}'{reason}, but found .", name, value); + + IEnumerable matched = Enumerable.Empty(); + + if (success) { + IEnumerable collection = genericCollection.Subject; + + if (ContainsCookieWithNameAndValue(collection, name, value)) { + Execute.Assertion + .BecauseOf(because, becauseArgs) + .FailWith("Expected {context:collection} {0} to not contain cookie with Name: '{1}' Value: '{2}'{reason}.", collection, name, value); + } + + matched = collection.Where(item => ContainsCookieWithNameAndValue(collection, name, value)); + } + + return new AndWhich(genericCollection, matched); + } + + /// + /// Determine if the collection contains a name/value matching cookie. + /// + /// + /// + /// + /// + /// + /// NOTE: There are other important criteria in Cookies like domain, path, etc... + /// If you want to check everything, don't use these extensions.. + /// + private static bool ContainsCookieWithNameAndValue(IEnumerable collection, string name, string value) { + foreach (Cookie cookie in collection) { + if (string.Compare(cookie.Name, name, StringComparison.OrdinalIgnoreCase) == 0 + && string.Compare(cookie.Value, value, StringComparison.Ordinal) == 0) { + return true; + } + } + return false; + } + +} diff --git a/test/RestSharp.Tests.Shared/RestSharp.Tests.Shared.csproj b/test/RestSharp.Tests.Shared/RestSharp.Tests.Shared.csproj index e15de7026..befda2aa8 100644 --- a/test/RestSharp.Tests.Shared/RestSharp.Tests.Shared.csproj +++ b/test/RestSharp.Tests.Shared/RestSharp.Tests.Shared.csproj @@ -3,6 +3,6 @@ false - + diff --git a/test/RestSharp.Tests/RestSharp.Tests.csproj b/test/RestSharp.Tests/RestSharp.Tests.csproj index 929f18c26..27f00528b 100644 --- a/test/RestSharp.Tests/RestSharp.Tests.csproj +++ b/test/RestSharp.Tests/RestSharp.Tests.csproj @@ -36,8 +36,8 @@ - - - + + + \ No newline at end of file