From f568d3c2bca0f2a427fb1bafe327bcb5980d2906 Mon Sep 17 00:00:00 2001 From: Pranav K Date: Fri, 21 Apr 2017 10:43:28 -0700 Subject: [PATCH] Support finding "sibling" pages when using RedirecToPage Fixes #6083 --- .../Internal/NormalizedRouteValue.cs | 57 ++++++ .../Internal/ViewEnginePath.cs | 95 ++++++++++ .../Properties/Resources.Designer.cs | 14 ++ .../Resources.resx | 3 + .../UrlHelperExtensions.cs | 26 ++- .../RazorViewEngine.cs | 97 +--------- .../Routing/UrlHelperTest.cs | 171 ++++++++++++++++-- .../RazorPagesTest.cs | 48 +++++ .../Pages/Redirects/RedirectToSibling.cshtml | 11 ++ .../Pages/Redirects/SubDir/SubDirPage.cshtml | 1 + 10 files changed, 413 insertions(+), 110 deletions(-) create mode 100644 src/Microsoft.AspNetCore.Mvc.Core/Internal/NormalizedRouteValue.cs create mode 100644 src/Microsoft.AspNetCore.Mvc.Core/Internal/ViewEnginePath.cs create mode 100644 test/WebSites/RazorPagesWebSite/Pages/Redirects/RedirectToSibling.cshtml create mode 100644 test/WebSites/RazorPagesWebSite/Pages/Redirects/SubDir/SubDirPage.cshtml diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Internal/NormalizedRouteValue.cs b/src/Microsoft.AspNetCore.Mvc.Core/Internal/NormalizedRouteValue.cs new file mode 100644 index 0000000000..6aeee5f9d9 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Core/Internal/NormalizedRouteValue.cs @@ -0,0 +1,57 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.AspNetCore.Mvc.Core.Internal +{ + public static class NormalizedRouteValue + { + /// + /// Gets the case-normalized route value for the specified route . + /// + /// The . + /// The route key to lookup. + /// The value corresponding to the key. + /// + /// The casing of a route value in is determined by the client. + /// This making constructing paths for view locations in a case sensitive file system unreliable. Using the + /// to get route values + /// produces consistently cased results. + /// + public static string GetNormalizedRouteValue(ActionContext context, string key) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (key == null) + { + throw new ArgumentNullException(nameof(key)); + } + + if (!context.RouteData.Values.TryGetValue(key, out var routeValue)) + { + return null; + } + + var actionDescriptor = context.ActionDescriptor; + string normalizedValue = null; + + if (actionDescriptor.RouteValues.TryGetValue(key, out var value) && + !string.IsNullOrEmpty(value)) + { + normalizedValue = value; + } + + var stringRouteValue = routeValue?.ToString(); + if (string.Equals(normalizedValue, stringRouteValue, StringComparison.OrdinalIgnoreCase)) + { + return normalizedValue; + } + + return stringRouteValue; + } + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Internal/ViewEnginePath.cs b/src/Microsoft.AspNetCore.Mvc.Core/Internal/ViewEnginePath.cs new file mode 100644 index 0000000000..514bb57719 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Core/Internal/ViewEnginePath.cs @@ -0,0 +1,95 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.AspNetCore.Mvc.Core.Internal +{ + public static class ViewEnginePath + { + private const string ParentDirectoryToken = ".."; + private static readonly char[] _pathSeparators = new[] { '/', '\\' }; + + public static string CombinePath(string first, string second) + { + Debug.Assert(!string.IsNullOrEmpty(first)); + + if (second.StartsWith("/", StringComparison.Ordinal)) + { + // "second" is already an app-rooted path. Return it as-is. + return second; + } + + string result; + + // Get directory name (including final slash) but do not use Path.GetDirectoryName() to preserve path + // normalization. + var index = first.LastIndexOf('/'); + Debug.Assert(index >= 0); + + if (index == first.Length - 1) + { + // If the first ends in a trailing slash e.g. "/Home/", assume it's a directory. + result = first + second; + } + else + { + result = first.Substring(0, index + 1) + second; + } + + return ResolvePath(result); + } + + public static string ResolvePath(string path) + { + if (!RequiresPathResolution(path)) + { + return path; + } + + var pathSegments = new List(); + var tokenizer = new StringTokenizer(path, _pathSeparators); + foreach (var segment in tokenizer) + { + if (segment.Length == 0) + { + // Ignore multiple directory separators + continue; + } + if (segment.Equals(ParentDirectoryToken, StringComparison.Ordinal)) + { + if (pathSegments.Count == 0) + { + // Don't resolve the path if we ever escape the file system root. We can't reason about it in a + // consistent way. + return path; + } + pathSegments.RemoveAt(pathSegments.Count - 1); + } + else + { + pathSegments.Add(segment); + } + } + + var builder = new StringBuilder(); + for (var i = 0; i < pathSegments.Count; i++) + { + var segment = pathSegments[i]; + builder.Append('/'); + builder.Append(segment.Buffer, segment.Offset, segment.Length); + } + + return builder.ToString(); + } + + private static bool RequiresPathResolution(string path) + { + return path.IndexOf(ParentDirectoryToken, StringComparison.Ordinal) != -1; + } + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Properties/Resources.Designer.cs b/src/Microsoft.AspNetCore.Mvc.Core/Properties/Resources.Designer.cs index a0ef1e7272..ce3db79de4 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/Properties/Resources.Designer.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/Properties/Resources.Designer.cs @@ -1284,6 +1284,20 @@ namespace Microsoft.AspNetCore.Mvc.Core internal static string FormatNoRoutesMatchedForPage(object p0) => string.Format(CultureInfo.CurrentCulture, GetString("NoRoutesMatchedForPage"), p0); + /// + /// The relative page path '{0}' can only can only be used while executing a Razor Page. Specify a root relative path with a leading '/' to generate a URL outside of a Razor Page. + /// + internal static string UrlHelper_RelativePagePathIsNotSupported + { + get => GetString("UrlHelper_RelativePagePathIsNotSupported"); + } + + /// + /// The relative page path '{0}' can only can only be used while executing a Razor Page. Specify a root relative path with a leading '/' to generate a URL outside of a Razor Page. + /// + internal static string FormatUrlHelper_RelativePagePathIsNotSupported(object p0) + => string.Format(CultureInfo.CurrentCulture, GetString("UrlHelper_RelativePagePathIsNotSupported"), p0); + private static string GetString(string name, params string[] formatterNames) { var value = _resourceManager.GetString(name); diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Resources.resx b/src/Microsoft.AspNetCore.Mvc.Core/Resources.resx index 5853677f91..8fd48ee9ce 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/Resources.resx +++ b/src/Microsoft.AspNetCore.Mvc.Core/Resources.resx @@ -403,4 +403,7 @@ No page named '{0}' matches the supplied values. + + The relative page path '{0}' can only can only be used while executing a Razor Page. Specify a root relative path with a leading '/' to generate a URL outside of a Razor Page. + \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc.Core/UrlHelperExtensions.cs b/src/Microsoft.AspNetCore.Mvc.Core/UrlHelperExtensions.cs index b6de84b716..541e8e174e 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/UrlHelperExtensions.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/UrlHelperExtensions.cs @@ -2,6 +2,9 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Diagnostics; +using Microsoft.AspNetCore.Mvc.Core; +using Microsoft.AspNetCore.Mvc.Core.Internal; using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.AspNetCore.Routing; @@ -413,7 +416,7 @@ namespace Microsoft.AspNetCore.Mvc var routeValues = new RouteValueDictionary(values); var ambientValues = urlHelper.ActionContext.RouteData.Values; - if (pageName == null) + if (string.IsNullOrEmpty(pageName)) { if (!routeValues.ContainsKey("page") && ambientValues.TryGetValue("page", out var value)) @@ -423,7 +426,7 @@ namespace Microsoft.AspNetCore.Mvc } else { - routeValues["page"] = pageName; + routeValues["page"] = CalculatePageName(urlHelper.ActionContext, pageName); } if (!routeValues.ContainsKey("formaction") && @@ -440,5 +443,24 @@ namespace Microsoft.AspNetCore.Mvc host: host, fragment: fragment); } + + private static object CalculatePageName(ActionContext actionContext, string pageName) + { + Debug.Assert(pageName.Length > 0); + // Paths not qualified with a leading slash are treated as relative to the current page. + if (pageName[0] != '/') + { + var currentPagePath = NormalizedRouteValue.GetNormalizedRouteValue(actionContext, "page"); + if (string.IsNullOrEmpty(currentPagePath)) + { + // Disallow the use sibling page routing, a Razor page specific feature, from a non-page action. + throw new InvalidOperationException(Resources.FormatUrlHelper_RelativePagePathIsNotSupported(pageName)); + } + + return ViewEnginePath.CombinePath(currentPagePath, pageName); + } + + return pageName; + } } } diff --git a/src/Microsoft.AspNetCore.Mvc.Razor/RazorViewEngine.cs b/src/Microsoft.AspNetCore.Mvc.Razor/RazorViewEngine.cs index 0c74f7dde2..264f71dc6d 100644 --- a/src/Microsoft.AspNetCore.Mvc.Razor/RazorViewEngine.cs +++ b/src/Microsoft.AspNetCore.Mvc.Razor/RazorViewEngine.cs @@ -8,6 +8,7 @@ using System.Globalization; using System.Linq; using System.Text; using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Mvc.Core.Internal; using Microsoft.AspNetCore.Mvc.Razor.Internal; using Microsoft.AspNetCore.Mvc.ViewEngines; using Microsoft.AspNetCore.Razor.Language; @@ -36,9 +37,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor private const string ControllerKey = "controller"; private const string PageKey = "page"; - private const string ParentDirectoryToken = ".."; private static readonly TimeSpan _cacheExpirationDuration = TimeSpan.FromMinutes(20); - private static readonly char[] _pathSeparators = new[] { '/', '\\' }; private readonly IRazorPageFactoryProvider _pageFactory; private readonly IRazorPageActivator _pageActivator; @@ -100,41 +99,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor /// produces consistently cased results. /// public static string GetNormalizedRouteValue(ActionContext context, string key) - { - if (context == null) - { - throw new ArgumentNullException(nameof(context)); - } - - if (key == null) - { - throw new ArgumentNullException(nameof(key)); - } - - object routeValue; - if (!context.RouteData.Values.TryGetValue(key, out routeValue)) - { - return null; - } - - var actionDescriptor = context.ActionDescriptor; - string normalizedValue = null; - - string value; - if (actionDescriptor.RouteValues.TryGetValue(key, out value) && - !string.IsNullOrEmpty(value)) - { - normalizedValue = value; - } - - var stringRouteValue = routeValue?.ToString(); - if (string.Equals(normalizedValue, stringRouteValue, StringComparison.OrdinalIgnoreCase)) - { - return normalizedValue; - } - - return stringRouteValue; - } + => NormalizedRouteValue.GetNormalizedRouteValue(context, key); /// public RazorPageResult FindPage(ActionContext context, string pageName) @@ -345,59 +310,10 @@ namespace Microsoft.AspNetCore.Mvc.Razor // path relative to currently-executing view, if any. // Not yet executing a view. Start in app root. absolutePath = "/" + pagePath; - } - else - { - // Get directory name (including final slash) but do not use Path.GetDirectoryName() to preserve path - // normalization. - var index = executingFilePath.LastIndexOf('/'); - Debug.Assert(index >= 0); - absolutePath = executingFilePath.Substring(0, index + 1) + pagePath; - if (!RequiresPathResolution(pagePath)) - { - return absolutePath; - } + return ViewEnginePath.ResolvePath(absolutePath); } - if (!RequiresPathResolution(pagePath)) - { - return absolutePath; - } - - var pathSegments = new List(); - var tokenizer = new StringTokenizer(absolutePath, _pathSeparators); - foreach (var segment in tokenizer) - { - if (segment.Length == 0) - { - // Ignore multiple directory separators - continue; - } - if (segment.Equals(ParentDirectoryToken, StringComparison.Ordinal)) - { - if (pathSegments.Count == 0) - { - // Don't resolve the path if we ever escape the file system root. We can't reason about it in a - // consistent way. - return absolutePath; - } - pathSegments.RemoveAt(pathSegments.Count - 1); - } - else - { - pathSegments.Add(segment); - } - } - - var builder = new StringBuilder(); - for (var i = 0; i < pathSegments.Count; i++) - { - var segment = pathSegments[i]; - builder.Append('/'); - builder.Append(segment.Buffer, segment.Offset, segment.Length); - } - - return builder.ToString(); + return ViewEnginePath.CombinePath(executingFilePath, pagePath); } // internal for tests @@ -585,10 +501,5 @@ namespace Microsoft.AspNetCore.Mvc.Razor // Though ./ViewName looks like a relative path, framework searches for that view using view locations. return name.EndsWith(ViewExtension, StringComparison.OrdinalIgnoreCase); } - - private static bool RequiresPathResolution(string path) - { - return path.IndexOf(ParentDirectoryToken, StringComparison.Ordinal) != -1; - } } } diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/Routing/UrlHelperTest.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/Routing/UrlHelperTest.cs index 0b6147bd5e..81fb7606cb 100644 --- a/test/Microsoft.AspNetCore.Mvc.Core.Test/Routing/UrlHelperTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/Routing/UrlHelperTest.cs @@ -941,7 +941,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing [Theory] [InlineData(null, null, null, "/", null, "/")] - [InlineData(null, null, null, "/Hello", null, "/Hello" )] + [InlineData(null, null, null, "/Hello", null, "/Hello")] [InlineData(null, null, null, "Hello", null, "/Hello")] [InlineData("/", null, null, "", null, "/")] [InlineData("/hello/", null, null, "/world", null, "/hello/world")] @@ -1052,7 +1052,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing .Callback((UrlRouteContext context) => actual = context); // Act - urlHelper.Object.Page("TestPage"); + urlHelper.Object.Page("/TestPage"); // Assert urlHelper.Verify(); @@ -1062,7 +1062,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing value => { Assert.Equal("page", value.Key); - Assert.Equal("TestPage", value.Value); + Assert.Equal("/TestPage", value.Value); }); Assert.Null(actual.Host); Assert.Null(actual.Protocol); @@ -1100,7 +1100,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing .Callback((UrlRouteContext context) => actual = context); // Act - urlHelper.Object.Page("TestPage", values); + urlHelper.Object.Page("/TestPage", values); // Assert urlHelper.Verify(); @@ -1115,7 +1115,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing value => { Assert.Equal("page", value.Key); - Assert.Equal("TestPage", value.Value); + Assert.Equal("/TestPage", value.Value); }); Assert.Null(actual.Host); Assert.Null(actual.Protocol); @@ -1132,7 +1132,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing .Callback((UrlRouteContext context) => actual = context); // Act - urlHelper.Object.Page("TestPage", new { id = 13 }, "https"); + urlHelper.Object.Page("/TestPage", new { id = 13 }, "https"); // Assert urlHelper.Verify(); @@ -1147,7 +1147,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing value => { Assert.Equal("page", value.Key); - Assert.Equal("TestPage", value.Value); + Assert.Equal("/TestPage", value.Value); }); Assert.Equal("https", actual.Protocol); Assert.Null(actual.Host); @@ -1164,7 +1164,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing .Callback((UrlRouteContext context) => actual = context); // Act - urlHelper.Object.Page("TestPage", new { id = 13 }, "https", "mytesthost"); + urlHelper.Object.Page("/TestPage", new { id = 13 }, "https", "mytesthost"); // Assert urlHelper.Verify(); @@ -1179,7 +1179,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing value => { Assert.Equal("page", value.Key); - Assert.Equal("TestPage", value.Value); + Assert.Equal("/TestPage", value.Value); }); Assert.Equal("https", actual.Protocol); Assert.Equal("mytesthost", actual.Host); @@ -1196,7 +1196,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing .Callback((UrlRouteContext context) => actual = context); // Act - urlHelper.Object.Page("TestPage", new { id = 13 }, "https", "mytesthost", "#toc"); + urlHelper.Object.Page("/TestPage", new { id = 13 }, "https", "mytesthost", "#toc"); // Assert urlHelper.Verify(); @@ -1211,7 +1211,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing value => { Assert.Equal("page", value.Key); - Assert.Equal("TestPage", value.Value); + Assert.Equal("/TestPage", value.Value); }); Assert.Equal("https", actual.Protocol); Assert.Equal("mytesthost", actual.Host); @@ -1354,14 +1354,134 @@ namespace Microsoft.AspNetCore.Mvc.Routing }); } + [Theory] + [InlineData("Sibling", "/Dir1/Dir2/Sibling")] + [InlineData("Dir3/Sibling", "/Dir1/Dir2/Dir3/Sibling")] + [InlineData("Dir4/Dir5/Index", "/Dir1/Dir2/Dir4/Dir5/Index")] + public void Page_CalculatesPathRelativeToViewEnginePath_WhenNotRooted(string pageName, string expected) + { + // Arrange + UrlRouteContext actual = null; + var routeData = new RouteData(); + var actionContext = GetActionContextForPage("/Dir1/Dir2/About"); + + var urlHelper = CreateMockUrlHelper(actionContext); + urlHelper.Setup(h => h.RouteUrl(It.IsAny())) + .Callback((UrlRouteContext context) => actual = context); + + // Act + urlHelper.Object.Page(pageName); + + // Assert + urlHelper.Verify(); + Assert.NotNull(actual); + Assert.Null(actual.RouteName); + Assert.Collection(Assert.IsType(actual.Values), + value => + { + Assert.Equal("page", value.Key); + Assert.Equal(expected, value.Value); + }); + } + + [Fact] + public void Page_CalculatesPathRelativeToViewEnginePath_ForIndexPagePaths() + { + // Arrange + var expected = "/Dir1/Dir2/Sibling"; + UrlRouteContext actual = null; + var actionContext = GetActionContextForPage("/Dir1/Dir2/"); + + var urlHelper = CreateMockUrlHelper(actionContext); + urlHelper.Setup(h => h.RouteUrl(It.IsAny())) + .Callback((UrlRouteContext context) => actual = context); + + // Act + urlHelper.Object.Page("Sibling"); + + // Assert + urlHelper.Verify(); + Assert.NotNull(actual); + Assert.Null(actual.RouteName); + Assert.Collection(Assert.IsType(actual.Values), + value => + { + Assert.Equal("page", value.Key); + Assert.Equal(expected, value.Value); + }); + } + + [Fact] + public void Page_CalculatesPathRelativeToViewEnginePath_WhenNotRooted_ForPageAtRoot() + { + // Arrange + var expected = "/SiblingName"; + UrlRouteContext actual = null; + var routeData = new RouteData(); + var actionContext = new ActionContext + { + ActionDescriptor = new ActionDescriptor + { + RouteValues = new Dictionary + { + { "page", "/Home" }, + }, + }, + RouteData = new RouteData + { + Values = + { + [ "page" ] = "/Home" + }, + }, + }; + + var urlHelper = CreateMockUrlHelper(actionContext); + urlHelper.Setup(h => h.RouteUrl(It.IsAny())) + .Callback((UrlRouteContext context) => actual = context); + + // Act + urlHelper.Object.Page("SiblingName"); + + // Assert + urlHelper.Verify(); + Assert.NotNull(actual); + Assert.Null(actual.RouteName); + Assert.Collection(Assert.IsType(actual.Values), + value => + { + Assert.Equal("page", value.Key); + Assert.Equal(expected, value.Value); + }); + } + + [Fact] + public void Page_Throws_IfRouteValueDoesNotIncludePageKey() + { + // Arrange + var expected = "SiblingName"; + UrlRouteContext actual = null; + var routeData = new RouteData(); + var actionContext = new ActionContext + { + RouteData = new RouteData(), + }; + + var urlHelper = CreateMockUrlHelper(actionContext); + urlHelper.Setup(h => h.RouteUrl(It.IsAny())) + .Callback((UrlRouteContext context) => actual = context); + + // Act & Assert + var ex = Assert.Throws(() => urlHelper.Object.Page(expected)); + Assert.Equal($"The relative page path '{expected}' can only can only be used while executing a Razor Page. " + + "Specify a root relative path with a leading '/' to generate a URL outside of a Razor Page.", ex.Message); + } + private static Mock CreateMockUrlHelper(ActionContext context = null) { if (context == null) { - context = new ActionContext - { - RouteData = new RouteData(), - }; + context = GetActionContextForPage("/Page"); } var urlHelper = new Mock(); @@ -1513,6 +1633,27 @@ namespace Microsoft.AspNetCore.Mvc.Routing return routeBuilder.Build(); } + private static ActionContext GetActionContextForPage(string page) + { + return new ActionContext + { + ActionDescriptor = new ActionDescriptor + { + RouteValues = new Dictionary + { + { "page", page }, + }, + }, + RouteData = new RouteData + { + Values = + { + [ "page" ] = page + }, + }, + }; + } + private class PassThroughRouter : IRouter { public VirtualPathData GetVirtualPath(VirtualPathContext context) diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RazorPagesTest.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RazorPagesTest.cs index 7624f5335a..e62c4e1398 100644 --- a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RazorPagesTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RazorPagesTest.cs @@ -855,6 +855,54 @@ Microsoft.AspNetCore.Mvc.ViewFeatures.ViewDataDictionary`1[AspNetCore._InjectedP Assert.Equal(expected, response.Headers.Location.ToString()); } + [Fact] + public async Task RedirectToSibling_Works() + { + // Arrange + var expected = "/Pages/Redirects/Redirect/10"; + var response = await Client.GetAsync("/Pages/Redirects/RedirectToSibling/RedirectToRedirect"); + + // Assert + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + Assert.Equal(expected, response.Headers.Location.ToString()); + } + + [Fact] + public async Task RedirectToSibling_RedirectsToIndexPage_WithoutIndexSegment() + { + // Arrange + var expected = "/Pages/Redirects"; + var response = await Client.GetAsync("/Pages/Redirects/RedirectToSibling/RedirectToIndex"); + + // Assert + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + Assert.Equal(expected, response.Headers.Location.ToString()); + } + + [Fact] + public async Task RedirectToSibling_RedirectsToSubDirectory() + { + // Arrange + var expected = "/Pages/Redirects/SubDir/SubDirPage"; + var response = await Client.GetAsync("/Pages/Redirects/RedirectToSibling/RedirectToSubDir"); + + // Assert + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + Assert.Equal(expected, response.Headers.Location.ToString()); + } + + [Fact] + public async Task RedirectToSibling_RedirectsToParentDirectory() + { + // Arrange + var expected = "/Pages/Conventions/AuthFolder"; + var response = await Client.GetAsync("/Pages/Redirects/RedirectToSibling/RedirectToParent"); + + // Assert + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + Assert.Equal(expected, response.Headers.Location.ToString()); + } + private async Task AddAntiforgeryHeaders(HttpRequestMessage request) { var getResponse = await Client.GetAsync(request.RequestUri); diff --git a/test/WebSites/RazorPagesWebSite/Pages/Redirects/RedirectToSibling.cshtml b/test/WebSites/RazorPagesWebSite/Pages/Redirects/RedirectToSibling.cshtml new file mode 100644 index 0000000000..445b259c6b --- /dev/null +++ b/test/WebSites/RazorPagesWebSite/Pages/Redirects/RedirectToSibling.cshtml @@ -0,0 +1,11 @@ +@page "{formaction?}" +@functions +{ + public IActionResult OnGetRedirectToIndex() => RedirectToPage("Index"); + + public IActionResult OnGetRedirectToRedirect() => RedirectToPage("Redirect", new { id = 10 }); + + public IActionResult OnGetRedirectToSubDir() => RedirectToPage("SubDir/SubDirPage"); + + public IActionResult OnGetRedirectToParent() => RedirectToPage("../Conventions/AuthFolder/Index"); +} diff --git a/test/WebSites/RazorPagesWebSite/Pages/Redirects/SubDir/SubDirPage.cshtml b/test/WebSites/RazorPagesWebSite/Pages/Redirects/SubDir/SubDirPage.cshtml new file mode 100644 index 0000000000..dce99a3fbc --- /dev/null +++ b/test/WebSites/RazorPagesWebSite/Pages/Redirects/SubDir/SubDirPage.cshtml @@ -0,0 +1 @@ +@page