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/RestClientOptions.cs b/src/RestSharp/Options/RestClientOptions.cs index 95db15020..4d0bdf2bc 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; } /// @@ -60,7 +66,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; } @@ -86,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; } @@ -131,10 +137,25 @@ 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; + /// + /// Note: This now delegates the property implementation to . + /// + 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 new file mode 100644 index 000000000..224bdeb70 --- /dev/null +++ b/src/RestSharp/Options/RestClientRedirectionOptions.cs @@ -0,0 +1,143 @@ +using RestSharp.Extensions; +using System.Net; +using System.Reflection; + +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. + /// + /// NOTE: The 'Authorization' header is controlled by , + /// and the 'Cookie' header is controlled by . + /// + 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 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 + /// . + /// + 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; } = false; + + /// + /// 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; + + /// + /// The maximum number of redirects to follow. + /// + public int MaxRedirects { get; set; } = 50; + + /// + /// 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; + + /// + /// 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; + + /// + /// 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), + /// TemporaryRedirect (307), + /// Redirect (302), + /// PermanentRedirect (308) + /// + 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..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); @@ -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; 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 9c99b9fd7..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. @@ -13,6 +13,7 @@ // limitations under the License. using System.Net; +using System.Web; using RestSharp.Extensions; namespace RestSharp; @@ -69,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)); @@ -77,7 +88,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; @@ -85,53 +96,222 @@ 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); - - using var message = new HttpRequestMessage(httpMethod, url) { Content = requestContent.BuildContent() }; - message.Headers.Host = Options.BaseHost; - message.Headers.CacheControl = request.CachePolicy ?? Options.CachePolicy; + var originalUrl = url; using var timeoutCts = new CancellationTokenSource(request.Timeout > 0 ? request.Timeout : int.MaxValue); using var cts = CancellationTokenSource.CreateLinkedTokenSource(timeoutCts.Token, cancellationToken); var ct = cts.Token; - - HttpResponseMessage? responseMessage; + HttpResponseMessage? responseMessage = null; // 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); + 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 { - responseMessage = await HttpClient.SendAsync(message, request.CompletionOption, ct).ConfigureAwait(false); + var headers = new RequestHeaders() + .AddHeaders(request.Parameters) + .AddHeaders(DefaultParameters) + .AddAcceptHeader(AcceptedContentTypes) + .AddCookieHeaders(url, cookieContainer) + .AddCookieHeaders(url, Options.CookieContainer); + + 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(omitBody: state.HasFlag(ExecutionState.DoNotSendBody)); + + // If we found coookies during a redirect, + // we need to update the Cookie headers: + if (state.HasFlag(ExecutionState.FoundCookie)) { + headers.AddCookieHeaders(url, cookieContainer); + // Clear the state: + state &= ~ExecutionState.FoundCookie; + } + using var message = PrepareRequestMessage(httpMethod, url, content, headers); + + if (state.HasFlag(ExecutionState.FirstAttempt)) { + state &= ~ExecutionState.FirstAttempt; + 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 (!IsRedirect(Options.RedirectOptions, responseMessage)) { + break; + } + + var location = responseMessage.Headers.Location; + + if (location == null) { + break; + } + + redirectCount++; + if (redirectCount >= Options.RedirectOptions.MaxRedirects) { + break; + } + + if (!location.IsAbsoluteUri) { + location = new Uri(url, location); + } + + if (Options.RedirectOptions.ForwardQuery) { + string oringalQuery = originalUrl.Query; + 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); + } + 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. + 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 + // based on the option setting: + if (HttpUtilities.IsSupportedSecureScheme(originalUrl.Scheme) + && !HttpUtilities.IsSupportedSecureScheme(location.Scheme) + && !Options.RedirectOptions.FollowRedirectsToInsecure) { + // TODO: Log here... + break; + } + + // 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; + state |= ExecutionState.VerbAltered; + } + else if (responseMessage.StatusCode == HttpStatusCode.RedirectMethod) { + state |= ExecutionState.VerbAlterationPrevented; + } + + // 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 (!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: + 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 + && string.Equals((string)transferEncoding.Value!, "chunked", StringComparison.OrdinalIgnoreCase)) { + message.Headers.Remove(KnownHeaders.TransferEncoding); + } + } + } + + 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) { + state |= ExecutionState.FoundCookie; + } + // 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(KnownHeaders.Authorization); + } + if (!Options.RedirectOptions.ForwardCookies) { + headers.Parameters.RemoveParameter(KnownHeaders.Cookie); + } + } + 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 - 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 (RestClientInternalException e) { + throw e.InnerException!; + } catch (Exception ex) { return new HttpResponse(null, url, null, ex, timeoutCts.Token); } if (request.OnAfterRequest != null) await request.OnAfterRequest(responseMessage).ConfigureAwait(false); await OnAfterRequest(responseMessage).ConfigureAwait(false); return new HttpResponse(responseMessage, url, cookieContainer, null, timeoutCts.Token); - } /// @@ -162,6 +342,64 @@ 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 + /// + /// + /// + /// 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 + => 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); + + return message; + } + + static bool IsRedirect(RestClientRedirectionOptions options, HttpResponseMessage responseMessage) { + return options.FollowRedirects && options.RedirectStatusCodes.Contains(responseMessage.StatusCode); + } + record HttpResponse( HttpResponseMessage? ResponseMessage, Uri Url, 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.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.InteractiveTests/RestSharp.InteractiveTests.csproj b/test/RestSharp.InteractiveTests/RestSharp.InteractiveTests.csproj index c25f980cf..38ab23946 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/RedirectOptionsTest.cs b/test/RestSharp.Tests.Integrated/RedirectOptionsTest.cs new file mode 100644 index 000000000..707c0ab2f --- /dev/null +++ b/test/RestSharp.Tests.Integrated/RedirectOptionsTest.cs @@ -0,0 +1,795 @@ +using RestSharp.Tests.Integrated.Server; +using System.Net; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using RestSharp.Tests.Shared.Extensions; + +namespace RestSharp.Tests.Integrated { + [Collection(nameof(TestServerCollection))] + 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; + } + + RestClientOptions NewOptions() { + return new RestClientOptions(_baseUri); + } + + [Fact] + 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 + // 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':") + .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; + // 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: + .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); + // 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] + 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"); + + // 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() + // 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(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] + 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.StatusCode.Should().Be(HttpStatusCode.OK); + response.ResponseUri.Should().Be($"{_baseUri}dump-headers?url=%2fdump-headers"); + var content = response.Content; + 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.Should().ContainCookieWithNameAndValue("redirectCookie", "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.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: + .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.Should().ContainCookieWithNameAndValue("redirectCookie", "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.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: + .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.Should().ContainCookieWithNameAndValue("redirectCookie", "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.StatusCode.Should().Be(HttpStatusCode.OK); + response.ResponseUri.Should().Be($"{_baseUri}dump-headers"); + var content = response.Content; + 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.Should().ContainCookieWithNameAndValue("redirectCookie", "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.StatusCode.Should().Be(HttpStatusCode.OK); + response.ResponseUri.Should().Be($"{_baseUri}dump-headers?url=%2fdump-headers#fragmentName"); + var content = response.Content; + 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.Should().ContainCookieWithNameAndValue("redirectCookie", "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.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().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.Should().ContainCookieWithNameAndValue("redirectCookie", "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.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.Should().ContainCookieWithNameAndValue("redirectCookie", "value1"); + } + + [Fact] + public async Task Can_RedirectBelowMaxRedirects_WithLoweredValue() { + var options = NewOptions(); + options.RedirectOptions.MaxRedirects = 6; + var client = new RestClient(options); + + // 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); + 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 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!"); + } + + [Fact] + public async Task Can_RedirectAtMaxRedirects() { + var options = NewOptions(); + var client = new RestClient(options); + + // 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!"); + } + + [Fact] + public async Task Can_StopRedirectAboveMaxRedirectDefault() { + var options = NewOptions(); + var client = new RestClient(options); + + // 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!"); + } + + [Fact] + public async Task Can_StopRedirectAboveMaxRedirectSet() { + var options = NewOptions(); + options.RedirectOptions.MaxRedirects = 5; + var client = new RestClient(options); + + // 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, + SslPolicyErrors sslPolicyErrors) { + return true; + } + + [Fact] + public async Task Can_FailToRedirectToInsecureUrl() { + var options = NewOptions(); + options.RemoteCertificateValidationCallback = RemoteCertificateValidationCallback; + var client = new RestClient(options); + + // 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; + 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_True() { + var options = NewOptions(); + options.RemoteCertificateValidationCallback = RemoteCertificateValidationCallback; + options.RedirectOptions.FollowRedirectsToInsecure = true; + var client = new RestClient(options); + + // 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"); + } + + [Fact] + public async Task Can_RedirectToSecureUrl() { + var options = NewOptions(); + options.RemoteCertificateValidationCallback = RemoteCertificateValidationCallback; + var client = new RestClient(options); + + // 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':") + .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 47b4954a2..51a02c7f1 100644 --- a/test/RestSharp.Tests.Integrated/RedirectTests.cs +++ b/test/RestSharp.Tests.Integrated/RedirectTests.cs @@ -15,18 +15,21 @@ using System.Net; using RestSharp.Tests.Integrated.Server; +using RestSharp.Tests.Shared.Extensions; 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 }; _client = new RestClient(options); + _host = _client.Options.BaseUrl!.Host; } [Fact] @@ -40,6 +43,105 @@ 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_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().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] + 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.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"); + } + + [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.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"); + } + + [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.Should().ContainCookieWithNameAndValue("redirectCookie", "putCookieValue1"); + // However, the redirection status code should be a 405 (Method Not Allowed): + 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?url=%2fdump-headers"); + var content = response.Content; + 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.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: Altered Redirect Status Codes list + + class Response { public string? Message { get; set; } } diff --git a/test/RestSharp.Tests.Integrated/RestSharp.Tests.Integrated.csproj b/test/RestSharp.Tests.Integrated/RestSharp.Tests.Integrated.csproj index cd511eb9b..22ca7a974 100644 --- a/test/RestSharp.Tests.Integrated/RestSharp.Tests.Integrated.csproj +++ b/test/RestSharp.Tests.Integrated/RestSharp.Tests.Integrated.csproj @@ -20,8 +20,14 @@ + + + + + PreserveNewest + \ No newline at end of file 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; + } +} diff --git a/test/RestSharp.Tests.Integrated/Server/TestServer.cs b/test/RestSharp.Tests.Integrated/Server/TestServer.cs index dd075532a..495e2ef4d 100644 --- a/test/RestSharp.Tests.Integrated/Server/TestServer.cs +++ b/test/RestSharp.Tests.Integrated/Server/TestServer.cs @@ -5,6 +5,13 @@ using Microsoft.Extensions.Logging; using RestSharp.Tests.Integrated.Server.Handlers; 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; + // ReSharper disable ConvertClosureToMethodGroup namespace RestSharp.Tests.Integrated.Server; @@ -13,6 +20,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"; @@ -22,8 +30,23 @@ 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; }); + // 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(); _app.MapControllers(); @@ -36,11 +59,119 @@ 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); + }); + _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"; + var queryString = HttpUtility.ParseQueryString(ctx.Request.QueryString.Value ?? string.Empty); + 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(); + foreach (var kvp in headers) { + sb.Append($"'{kvp.Key}': '{kvp.Value}',"); + } + 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", + (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)); + _app.MapGet( + "get-cookies-redirect", + (HttpContext ctx) => { + ctx.Response.Cookies.Append("redirectCookie", "value1"); + string redirectDestination = "/get-cookies"; + var queryString = HttpUtility.ParseQueryString(ctx.Request.QueryString.Value ?? string.Empty); + var urlParameter = queryString.Get("url"); + if (!string.IsNullOrEmpty(urlParameter)) { + redirectDestination = urlParameter; + } + return Results.Redirect(redirectDestination, false, true); + } + ); + + _app.MapPost( + "/post/set-cookie-redirect", + (HttpContext ctx) => { + 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( @@ -64,9 +195,19 @@ 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); + 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 000000000..bb6e35812 Binary files /dev/null and b/test/RestSharp.Tests.Integrated/Server/testCert.pfx differ 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..7cda432ff 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..f1b45e9b1 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..2625d739f 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/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/Fixtures/SimpleServer.cs b/test/RestSharp.Tests.Shared/Fixtures/SimpleServer.cs index d53863c2f..ea753ac68 100644 --- a/test/RestSharp.Tests.Shared/Fixtures/SimpleServer.cs +++ b/test/RestSharp.Tests.Shared/Fixtures/SimpleServer.cs @@ -32,7 +32,15 @@ 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, + // or the TestServer insecure/secure ports: + if (port == 8888 + || port == 5151 + || port == 5152) { + goto TryAgain; + } return new SimpleServer(port, handler, authenticationSchemes); } diff --git a/test/RestSharp.Tests.Shared/RestSharp.Tests.Shared.csproj b/test/RestSharp.Tests.Shared/RestSharp.Tests.Shared.csproj index 274f12fb9..befda2aa8 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/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; diff --git a/test/RestSharp.Tests/RestSharp.Tests.csproj b/test/RestSharp.Tests/RestSharp.Tests.csproj index eda460e5b..27f00528b 100644 --- a/test/RestSharp.Tests/RestSharp.Tests.csproj +++ b/test/RestSharp.Tests/RestSharp.Tests.csproj @@ -4,9 +4,17 @@ + + true + + + + + + @@ -27,4 +35,9 @@ + + + + + \ No newline at end of file