diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/Common/WebRequestPSCmdlet.Common.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/Common/WebRequestPSCmdlet.Common.cs index 20b72d0fe77..789a4a18502 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/Common/WebRequestPSCmdlet.Common.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/Common/WebRequestPSCmdlet.Common.cs @@ -1789,7 +1789,7 @@ internal void ParseLinkHeader(HttpResponseMessage response, System.Uri requestUr // we only support the URL in angle brackets and `rel`, other attributes are ignored // user can still parse it themselves via the Headers property - string pattern = "<(?.*?)>;\\srel=\"(?.*?)\""; + string pattern = "<(?.*?)>;\\s*rel=\"(?.*?)\""; IEnumerable links; if (response.Headers.TryGetValues("Link", out links)) { diff --git a/test/powershell/Modules/Microsoft.PowerShell.Utility/WebCmdlets.Tests.ps1 b/test/powershell/Modules/Microsoft.PowerShell.Utility/WebCmdlets.Tests.ps1 index 775a5e8ca5a..d614ebbab6e 100644 --- a/test/powershell/Modules/Microsoft.PowerShell.Utility/WebCmdlets.Tests.ps1 +++ b/test/powershell/Modules/Microsoft.PowerShell.Utility/WebCmdlets.Tests.ps1 @@ -739,6 +739,23 @@ Describe "Invoke-WebRequest tests" -Tags "Feature" { $result.Output.RelationLink["self"] | Should -BeExactly "${baseUri}?maxlinks=3&linknumber=1&type=${type}" } + It "Validate Invoke-WebRequest handles different whitespace for Link Headers: " -TestCases @( + @{ type = "noWhitespace" } + @{ type = "extraWhitespace" } + ) { + param($type) + $uri = Get-WebListenerUrl -Test 'Link' -Query @{type = $type} + $command = "Invoke-WebRequest -Uri '$uri'" + $result = ExecuteWebCommand -command $command + + $result.Output.RelationLink.Count | Should -BeExactly 4 + $baseUri = Get-WebListenerUrl -Test 'Link' + $result.Output.RelationLink["last"] | Should -BeExactly "${baseUri}?maxlinks=3&linknumber=3&type=${type}" + $result.Output.RelationLink["first"] | Should -BeExactly "${baseUri}?maxlinks=3&linknumber=1&type=${type}" + $result.Output.RelationLink["self"] | Should -BeExactly "${baseUri}?maxlinks=3&linknumber=1&type=${type}" + $result.Output.RelationLink["next"] | Should -BeExactly "${baseUri}?maxlinks=3&linknumber=2&type=${type}" + } + #region Redirect tests It "Validates Invoke-WebRequest with -PreserveAuthorizationOnRedirect preserves the authorization header on redirect: " -TestCases $redirectTests { @@ -2072,6 +2089,17 @@ Describe "Invoke-RestMethod tests" -Tags "Feature" { $result.Output.linknumber | Should -BeExactly 1 } + It "Validate Invoke-RestMethod handles whitespace for Link Headers if -FollowRelLink is specified: " -TestCases @( + @{ type = "noWhitespace" } + @{ type = "extraWhitespace" } + ) { + param($type) + $uri = Get-WebListenerUrl -Test 'Link' -Query @{type = $type} + $command = "Invoke-RestMethod -Uri '$uri' -FollowRelLink" + $result = ExecuteWebCommand -command $command + 1..3 | ForEach-Object { $result.Output[$_ - 1].linknumber | Should -BeExactly $_ } + } + #region Redirect tests It "Validates Invoke-RestMethod with -PreserveAuthorizationOnRedirect preserves the authorization header on redirect: " -TestCases $redirectTests { diff --git a/test/tools/WebListener/Constants.cs b/test/tools/WebListener/Constants.cs index db4585163a0..4d43888c425 100644 --- a/test/tools/WebListener/Constants.cs +++ b/test/tools/WebListener/Constants.cs @@ -8,7 +8,7 @@ internal static class Constants { public const string HeaderSeparator = ", "; public const string ApplicationJson = "application/json"; - public const string LinkUriTemplate = "<{0}?maxlinks={1}&linknumber={2}&type={3}>; rel=\"{4}\""; + public const string LinkUriTemplate = "<{0}?maxlinks={1}&linknumber={2}&type={3}>;{4}rel=\"{5}\""; public const string MalformedUrlLinkHeader = "{url}; foo"; public const string NoRelLinkHeader = "; foo=\"bar\""; public const string NoUrlLinkHeader = "<>; rel=\"next\""; diff --git a/test/tools/WebListener/Controllers/LinkController.cs b/test/tools/WebListener/Controllers/LinkController.cs index a24b1895a1d..e699dd325ca 100644 --- a/test/tools/WebListener/Controllers/LinkController.cs +++ b/test/tools/WebListener/Controllers/LinkController.cs @@ -31,14 +31,24 @@ public JsonResult Index() string type = Request.Query.TryGetValue("type", out StringValues typeSV) ? typeSV.FirstOrDefault() : "default"; + string whitespace = " "; + if (type.ToUpper() == "EXTRAWHITESPACE") + { + whitespace = " "; + } + else if (type.ToUpper() == "NOWHITESPACE") + { + whitespace = string.Empty; + } + var linkList = new List(); if (maxLinks > 1 && linkNumber > 1) { - linkList.Add(GetLink(baseUri: baseUri, maxLinks: maxLinks, linkNumber: linkNumber - 1, type: type, rel: "prev")); + linkList.Add(GetLink(baseUri: baseUri, maxLinks: maxLinks, linkNumber: linkNumber - 1, type: type, whitespace: whitespace, rel: "prev")); } - linkList.Add(GetLink(baseUri: baseUri, maxLinks: maxLinks, linkNumber: maxLinks, type: type, rel: "last")); - linkList.Add(GetLink(baseUri: baseUri, maxLinks: maxLinks, linkNumber: 1, type: type, rel: "first")); - linkList.Add(GetLink(baseUri: baseUri, maxLinks: maxLinks, linkNumber: linkNumber, type: type, rel: "self")); + linkList.Add(GetLink(baseUri: baseUri, maxLinks: maxLinks, linkNumber: maxLinks, type: type, whitespace: whitespace, rel: "last")); + linkList.Add(GetLink(baseUri: baseUri, maxLinks: maxLinks, linkNumber: 1, type: type, whitespace: whitespace, rel: "first")); + linkList.Add(GetLink(baseUri: baseUri, maxLinks: maxLinks, linkNumber: linkNumber, type: type, whitespace: whitespace, rel: "self")); bool sendMultipleHeaders = false; bool skipNextLink = false; @@ -65,7 +75,7 @@ public JsonResult Index() if (!skipNextLink && maxLinks > 1 && linkNumber < maxLinks) { - linkList.Add(GetLink(baseUri: baseUri, maxLinks: maxLinks, linkNumber: linkNumber + 1, type: type, rel: "next")); + linkList.Add(GetLink(baseUri: baseUri, maxLinks: maxLinks, linkNumber: linkNumber + 1, type: type, whitespace: whitespace, rel: "next")); } StringValues linkHeader; @@ -96,9 +106,9 @@ public IActionResult Error() return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier }); } - private string GetLink(string baseUri, int maxLinks, int linkNumber, string type, string rel) + private string GetLink(string baseUri, int maxLinks, int linkNumber, string whitespace, string type, string rel) { - return String.Format(Constants.LinkUriTemplate, baseUri, maxLinks, linkNumber, type, rel); + return string.Format(Constants.LinkUriTemplate, baseUri, maxLinks, linkNumber, type, whitespace, rel); } } } diff --git a/test/tools/WebListener/README.md b/test/tools/WebListener/README.md index 6abace80c6a..786ad697ea3 100644 --- a/test/tools/WebListener/README.md +++ b/test/tools/WebListener/README.md @@ -277,6 +277,8 @@ Returns Link response headers to test paginated results. The endpoint accepts 3 * `nourl` - Returns a Link header that does not include the URI portion. Suppresses `next` link. * `malformed` - Returns a malformed Link header. Suppresses `next` link. * `multiple` - Returns multiple Link headers instead of a single Link header and returns `next` link if one is available. + * `nowhitespace` - Returns `default` links without any whitespace between the semicolon and `rel` + * `extrawhitespace` - Returns `default` links with double whitespace between the semicolon and `rel` The body will contain the same results as `/Get/` with the addition of the `type`, `linknumber`, and `maxlinks` for the current page.