// 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.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Testing; using Microsoft.Extensions.ObjectPool; using Microsoft.Extensions.Options; using Moq; using Xunit; namespace Microsoft.AspNetCore.Routing { public class RouteCollectionTest { private static readonly RequestDelegate NullHandler = (c) => Task.CompletedTask; [Theory] [InlineData(@"Home/Index/23", "/home/index/23", true, false)] [InlineData(@"Home/Index/23", "/Home/Index/23", false, false)] [InlineData(@"Home/Index/23", "/home/index/23/", true, true)] [InlineData(@"Home/Index/23", "/Home/Index/23/", false, true)] [InlineData(@"Home/Index/23?Param1=ABC&Param2=Xyz", "/Home/Index/23/?Param1=ABC&Param2=Xyz", false, true)] [InlineData(@"Home/Index/23?Param1=ABC&Param2=Xyz", "/Home/Index/23?Param1=ABC&Param2=Xyz", false, false)] [InlineData(@"Home/Index/23?Param1=ABC&Param2=Xyz", "/home/index/23/?Param1=ABC&Param2=Xyz", true, true)] [InlineData(@"Home/Index/23#Param1=ABC&Param2=Xyz", "/Home/Index/23/#Param1=ABC&Param2=Xyz", false, true)] [InlineData(@"Home/Index/23#Param1=ABC&Param2=Xyz", "/home/index/23#Param1=ABC&Param2=Xyz", true, false)] [InlineData(@"Home/Index/23/?Param1=ABC&Param2=Xyz", "/home/index/23/?Param1=ABC&Param2=Xyz", true, true)] [InlineData(@"Home/Index/23/#Param1=ABC&Param2=Xyz", "/home/index/23/#Param1=ABC&Param2=Xyz", true, false)] public void GetVirtualPath_CanLowerCaseUrls_And_AppendTrailingSlash_BasedOnOptions( string returnUrl, string expectedUrl, bool lowercaseUrls, bool appendTrailingSlash) { // Arrange var target = new Mock(MockBehavior.Strict); target .Setup(e => e.GetVirtualPath(It.IsAny())) .Returns(new VirtualPathData(target.Object, returnUrl)); var routeCollection = new RouteCollection(); routeCollection.Add(target.Object); var virtualPathContext = CreateVirtualPathContext( options: GetRouteOptions( lowerCaseUrls: lowercaseUrls, appendTrailingSlash: appendTrailingSlash)); // Act var pathData = routeCollection.GetVirtualPath(virtualPathContext); // Assert Assert.Equal(expectedUrl, pathData.VirtualPath); Assert.Same(target.Object, pathData.Router); Assert.Empty(pathData.DataTokens); } [Theory] [InlineData(@"\u0130", @"/\u0130", true)] [InlineData(@"\u0049", @"/\u0049", true)] [InlineData(@"�ino", @"/�ino", true)] public void GetVirtualPath_DoesntLowerCaseUrls_Invariant( string returnUrl, string lowercaseUrl, bool lowercaseUrls) { // Arrange var target = new Mock(MockBehavior.Strict); target .Setup(e => e.GetVirtualPath(It.IsAny())) .Returns(new VirtualPathData(target.Object, returnUrl)); var routeCollection = new RouteCollection(); routeCollection.Add(target.Object); var virtualPathContext = CreateVirtualPathContext(options: GetRouteOptions(lowercaseUrls)); // Act var pathData = routeCollection.GetVirtualPath(virtualPathContext); // Assert Assert.Equal(lowercaseUrl, pathData.VirtualPath); Assert.Same(target.Object, pathData.Router); Assert.Empty(pathData.DataTokens); } [Theory] [InlineData(@"Home/Index/23?Param1=ABC&Param2=Xyz", "/Home/Index/23?Param1=ABC&Param2=Xyz", false, true, false)] [InlineData(@"Home/Index/23?Param1=ABC&Param2=Xyz", "/Home/Index/23?Param1=ABC&Param2=Xyz", false, false, false)] [InlineData(@"Home/Index/23?Param1=ABC&Param2=Xyz", "/home/index/23/?param1=abc¶m2=xyz", true, true, true)] [InlineData(@"Home/Index/23#Param1=ABC&Param2=Xyz", "/Home/Index/23/#Param1=ABC&Param2=Xyz", false, true, true)] [InlineData(@"Home/Index/23#Param1=ABC&Param2=Xyz", "/home/index/23#Param1=ABC&Param2=Xyz", true, false, false)] [InlineData(@"Home/Index/23/?Param1=ABC&Param2=Xyz", "/home/index/23/?param1=abc¶m2=xyz", true, true, true)] [InlineData(@"Home/Index/23/#Param1=ABC&Param2=Xyz", "/home/index/23/#Param1=ABC&Param2=Xyz", true, false, true)] [InlineData(@"Home/Index/23/#Param1=ABC&Param2=Xyz", "/home/index/23/#param1=abc¶m2=xyz", true, true, true)] public void GetVirtualPath_CanLowerCaseUrls_QueryStrings_BasedOnOptions( string returnUrl, string expectedUrl, bool lowercaseUrls, bool lowercaseQueryStrings, bool appendTrailingSlash) { // Arrange var target = new Mock(MockBehavior.Strict); target .Setup(e => e.GetVirtualPath(It.IsAny())) .Returns(new VirtualPathData(target.Object, returnUrl)); var routeCollection = new RouteCollection(); routeCollection.Add(target.Object); var virtualPathContext = CreateVirtualPathContext( options: GetRouteOptions( lowerCaseUrls: lowercaseUrls, lowercaseQueryStrings: lowercaseQueryStrings, appendTrailingSlash: appendTrailingSlash)); // Act var pathData = routeCollection.GetVirtualPath(virtualPathContext); // Assert Assert.Equal(expectedUrl, pathData.VirtualPath); Assert.Same(target.Object, pathData.Router); Assert.Empty(pathData.DataTokens); } [Theory] [MemberData(nameof(DataTokensTestData))] public void GetVirtualPath_ReturnsDataTokens(RouteValueDictionary dataTokens, string routerName) { // Arrange var virtualPath = "/TestVirtualPath"; var pathContextValues = new RouteValueDictionary { { "controller", virtualPath } }; var pathContext = CreateVirtualPathContext( pathContextValues, GetRouteOptions(), routerName); var route = CreateTemplateRoute("{controller}", routerName, dataTokens); var routeCollection = new RouteCollection(); routeCollection.Add(route); var expectedDataTokens = dataTokens ?? new RouteValueDictionary(); // Act var pathData = routeCollection.GetVirtualPath(pathContext); // Assert Assert.NotNull(pathData); Assert.Same(route, pathData.Router); Assert.Equal(virtualPath, pathData.VirtualPath); Assert.Equal(expectedDataTokens.Count, pathData.DataTokens.Count); foreach (var dataToken in expectedDataTokens) { Assert.True(pathData.DataTokens.ContainsKey(dataToken.Key)); Assert.Equal(dataToken.Value, pathData.DataTokens[dataToken.Key]); } } [Fact] public async Task RouteAsync_FirstMatches() { // Arrange var routes = new RouteCollection(); var route1 = CreateRoute(accept: true); routes.Add(route1.Object); var route2 = CreateRoute(accept: false); routes.Add(route2.Object); var context = CreateRouteContext("/Cool"); // Act await routes.RouteAsync(context); // Assert route1.Verify(e => e.RouteAsync(It.IsAny()), Times.Exactly(1)); route2.Verify(e => e.RouteAsync(It.IsAny()), Times.Exactly(0)); Assert.NotNull(context.Handler); Assert.Equal(1, context.RouteData.Routers.Count); Assert.Same(route1.Object, context.RouteData.Routers[0]); } [Fact] public async Task RouteAsync_SecondMatches() { // Arrange var routes = new RouteCollection(); var route1 = CreateRoute(accept: false); routes.Add(route1.Object); var route2 = CreateRoute(accept: true); routes.Add(route2.Object); var context = CreateRouteContext("/Cool"); // Act await routes.RouteAsync(context); // Assert route1.Verify(e => e.RouteAsync(It.IsAny()), Times.Exactly(1)); route2.Verify(e => e.RouteAsync(It.IsAny()), Times.Exactly(1)); Assert.NotNull(context.Handler); Assert.Equal(1, context.RouteData.Routers.Count); Assert.Same(route2.Object, context.RouteData.Routers[0]); } [Fact] public async Task RouteAsync_NoMatch() { // Arrange var routes = new RouteCollection(); var route1 = CreateRoute(accept: false); routes.Add(route1.Object); var route2 = CreateRoute(accept: false); routes.Add(route2.Object); var context = CreateRouteContext("/Cool"); // Act await routes.RouteAsync(context); // Assert route1.Verify(e => e.RouteAsync(It.IsAny()), Times.Exactly(1)); route2.Verify(e => e.RouteAsync(It.IsAny()), Times.Exactly(1)); Assert.Null(context.Handler); Assert.Empty(context.RouteData.Routers); } [Theory] [InlineData(false, "/RouteName")] [InlineData(true, "/routename")] public void NamedRouteTests_GetNamedRoute_ReturnsValue(bool lowercaseUrls, string expectedUrl) { // Arrange var routeCollection = GetNestedRouteCollection(new string[] { "Route1", "Route2", "RouteName", "Route3" }); var virtualPathContext = CreateVirtualPathContext( routeName: "RouteName", options: GetRouteOptions(lowercaseUrls)); // Act var pathData = routeCollection.GetVirtualPath(virtualPathContext); // Assert Assert.Equal(expectedUrl, pathData.VirtualPath); var namedRouter = Assert.IsAssignableFrom(pathData.Router); Assert.Equal(virtualPathContext.RouteName, namedRouter.Name); Assert.Empty(pathData.DataTokens); } [Fact] public void NamedRouteTests_GetNamedRoute_RouteNotFound() { // Arrange var routeCollection = GetNestedRouteCollection(new string[] { "Route1", "Route2", "Route3" }); var virtualPathContext = CreateVirtualPathContext("NonExistantRoute"); // Act var stringVirtualPath = routeCollection.GetVirtualPath(virtualPathContext); // Assert Assert.Null(stringVirtualPath); } [Fact] public void NamedRouteTests_GetNamedRoute_AmbiguousRoutesInCollection_DoesNotThrowForUnambiguousRoute() { // Arrange var routeCollection = GetNestedRouteCollection(new string[] { "Route1", "Route2", "Route3", "Route4" }); // Add Duplicate route. routeCollection.Add(CreateNamedRoute("Route3")); var virtualPathContext = CreateVirtualPathContext(routeName: "Route1", options: GetRouteOptions(true)); // Act var pathData = routeCollection.GetVirtualPath(virtualPathContext); // Assert Assert.Equal("/route1", pathData.VirtualPath); var namedRouter = Assert.IsAssignableFrom(pathData.Router); Assert.Equal("Route1", namedRouter.Name); Assert.Empty(pathData.DataTokens); } [Fact] public void NamedRouteTests_GetNamedRoute_AmbiguousRoutesInCollection_ThrowsForAmbiguousRoute() { // Arrange var ambiguousRoute = "ambiguousRoute"; var routeCollection = GetNestedRouteCollection(new string[] { "Route1", "Route2", ambiguousRoute, "Route4" }); // Add Duplicate route. routeCollection.Add(CreateNamedRoute(ambiguousRoute)); var virtualPathContext = CreateVirtualPathContext(routeName: ambiguousRoute, options: GetRouteOptions()); // Act & Assert var ex = Assert.Throws(() => routeCollection.GetVirtualPath(virtualPathContext)); Assert.Equal( "The supplied route name 'ambiguousRoute' is ambiguous and matched more than one route.", ex.Message); } [Fact] public void GetVirtualPath_AmbiguousRoutes_RequiresRouteValueValidation_Error() { // Arrange var namedRoute = CreateNamedRoute("Ambiguous", accept: false); var routeCollection = new RouteCollection(); routeCollection.Add(namedRoute); var innerRouteCollection = new RouteCollection(); innerRouteCollection.Add(namedRoute); routeCollection.Add(innerRouteCollection); var virtualPathContext = CreateVirtualPathContext("Ambiguous"); // Act & Assert var ex = Assert.Throws(() => routeCollection.GetVirtualPath(virtualPathContext)); Assert.Equal("The supplied route name 'Ambiguous' is ambiguous and matched more than one route.", ex.Message); } // "Integration" tests for RouteCollection public static IEnumerable IntegrationTestData { get { yield return new object[] { "{controller}/{action}", new RouteValueDictionary { { "controller", "Home" }, { "action", "Index" } }, "/home/index", true }; yield return new object[] { "{controller}/{action}/", new RouteValueDictionary { { "controller", "Home" }, { "action", "Index" } }, "/Home/Index", false }; yield return new object[] { "api/{action}/", new RouteValueDictionary { { "action", "Create" } }, "/api/create", true }; yield return new object[] { "api/{action}/{id}", new RouteValueDictionary { { "action", "Create" }, { "id", "23" }, { "Param1", "Value1" }, { "Param2", "Value2" } }, "/api/create/23?Param1=Value1&Param2=Value2", true }; yield return new object[] { "api/{action}/{id}", new RouteValueDictionary { { "action", "Create" }, { "id", "23" }, { "Param1", "Value1" }, { "Param2", "Value2" } }, "/api/Create/23?Param1=Value1&Param2=Value2", false }; } } [Theory] [MemberData(nameof(IntegrationTestData))] public void GetVirtualPath_Success( string template, RouteValueDictionary values, string expectedUrl, bool lowercaseUrls) { // Arrange var routeCollection = new RouteCollection(); var route = CreateTemplateRoute(template); routeCollection.Add(route); var context = CreateVirtualPathContext(values, options: GetRouteOptions(lowercaseUrls)); // Act var pathData = routeCollection.GetVirtualPath(context); // Assert Assert.Equal(expectedUrl, pathData.VirtualPath); Assert.Same(route, pathData.Router); Assert.Empty(pathData.DataTokens); } public static IEnumerable RestoresRouteDataForEachRouterData { get { // Here 'area' segment doesn't have a value but the later segments have values. This is an invalid // route match and the url generation should look into the next available route in the collection. yield return new object[] { new Route[] { CreateTemplateRoute("{area?}/{controller=Home}/{action=Index}/{id?}", "1"), CreateTemplateRoute("{controller=Home}/{action=Index}/{id?}", "2") }, new RouteValueDictionary(new { controller = "Test", action = "Index" }), "/Test", "2" }; // Here the segment 'a' is valid but 'b' is not as it would be empty. This would be an invalid route match, but // the route value of 'a' should still be present to be evaluated for the next available route. yield return new object[] { new[] { CreateTemplateRoute("{a}/{b?}/{c}", "1"), CreateTemplateRoute("{a=Home}/{b=Index}", "2") }, new RouteValueDictionary(new { a = "Test", c = "Foo" }), "/Test?c=Foo", "2" }; } } [Theory] [MemberData(nameof(RestoresRouteDataForEachRouterData))] public void GetVirtualPath_RestoresRouteData_ForEachRouter( Route[] routes, RouteValueDictionary routeValues, string expectedUrl, string expectedRouteToMatch) { // Arrange var routeCollection = new RouteCollection(); foreach (var route in routes) { routeCollection.Add(route); } var context = CreateVirtualPathContext(routeValues); // Act var pathData = routeCollection.GetVirtualPath(context); // Assert Assert.Equal(expectedUrl, pathData.VirtualPath); Assert.Same(expectedRouteToMatch, ((INamedRouter)pathData.Router).Name); Assert.Empty(pathData.DataTokens); } [Fact] public void GetVirtualPath_NoBestEffort_NoMatch() { // Arrange var route1 = CreateRoute(accept: false, match: false, matchValue: "bad"); var route2 = CreateRoute(accept: false, match: false, matchValue: "bad"); var route3 = CreateRoute(accept: false, match: false, matchValue: "bad"); var routeCollection = new RouteCollection(); routeCollection.Add(route1.Object); routeCollection.Add(route2.Object); routeCollection.Add(route3.Object); var virtualPathContext = CreateVirtualPathContext(); // Act var path = routeCollection.GetVirtualPath(virtualPathContext); Assert.Null(path); // All of these should be called route1.Verify(r => r.GetVirtualPath(It.IsAny()), Times.Once()); route2.Verify(r => r.GetVirtualPath(It.IsAny()), Times.Once()); route3.Verify(r => r.GetVirtualPath(It.IsAny()), Times.Once()); } // DataTokens test data for RouterCollection.GetVirtualPath public static IEnumerable DataTokensTestData { get { yield return new object[] { null, null }; yield return new object[] { new RouteValueDictionary(), null }; yield return new object[] { new RouteValueDictionary() { { "tokenKey", "tokenValue" } }, null }; yield return new object[] { null, "routerA" }; yield return new object[] { new RouteValueDictionary(), "routerA" }; yield return new object[] { new RouteValueDictionary() { { "tokenKey", "tokenValue" } }, "routerA" }; } } private static RouteCollection GetRouteCollectionWithNamedRoutes(IEnumerable routeNames) { var routes = new RouteCollection(); foreach (var routeName in routeNames) { var route1 = CreateNamedRoute(routeName, accept: true); routes.Add(route1); } return routes; } private static RouteCollection GetNestedRouteCollection(string[] routeNames) { var random = new Random(); int index = random.Next(0, routeNames.Length - 1); var first = routeNames.Take(index).ToArray(); var second = routeNames.Skip(index).ToArray(); var rc1 = GetRouteCollectionWithNamedRoutes(first); var rc2 = GetRouteCollectionWithNamedRoutes(second); var rc3 = new RouteCollection(); var rc4 = new RouteCollection(); rc1.Add(rc3); rc4.Add(rc2); // Add a few unnamedRoutes. rc1.Add(CreateRoute(accept: false).Object); rc2.Add(CreateRoute(accept: false).Object); rc3.Add(CreateRoute(accept: false).Object); rc3.Add(CreateRoute(accept: false).Object); rc4.Add(CreateRoute(accept: false).Object); rc4.Add(CreateRoute(accept: false).Object); var routeCollection = new RouteCollection(); routeCollection.Add(rc1); routeCollection.Add(rc4); return routeCollection; } private static INamedRouter CreateNamedRoute(string name, bool accept = false, string matchValue = null) { if (matchValue == null) { matchValue = name; } var target = new Mock(MockBehavior.Strict); target .Setup(e => e.GetVirtualPath(It.IsAny())) .Returns(c => c.RouteName == name ? new VirtualPathData(target.Object, matchValue) : null) .Verifiable(); target .SetupGet(e => e.Name) .Returns(name); target .Setup(e => e.RouteAsync(It.IsAny())) .Callback((c) => c.Handler = accept ? NullHandler : null) .Returns(Task.FromResult(null)) .Verifiable(); return target.Object; } private static Route CreateTemplateRoute( string template, string routerName = null, RouteValueDictionary dataTokens = null, IInlineConstraintResolver constraintResolver = null) { var target = new Mock(MockBehavior.Strict); target .Setup(e => e.GetVirtualPath(It.IsAny())) .Returns(rc => null); if (constraintResolver == null) { constraintResolver = new Mock().Object; } return new Route( target.Object, routerName, template, defaults: null, constraints: null, dataTokens: dataTokens, inlineConstraintResolver: constraintResolver); } private static VirtualPathContext CreateVirtualPathContext( string routeName = null, ILoggerFactory loggerFactory = null, Action options = null) { if (loggerFactory == null) { loggerFactory = NullLoggerFactory.Instance; } var request = new Mock(MockBehavior.Strict); var services = new ServiceCollection(); services.AddSingleton(); services.AddOptions(); services.AddRouting(); if (options != null) { services.Configure(options); } var context = new Mock(MockBehavior.Strict); context.SetupGet(m => m.RequestServices).Returns(services.BuildServiceProvider()); context.SetupGet(c => c.Request).Returns(request.Object); return new VirtualPathContext(context.Object, null, null, routeName); } private static VirtualPathContext CreateVirtualPathContext( RouteValueDictionary values, Action options = null, string routeName = null) { var services = new ServiceCollection(); services.AddSingleton(NullLoggerFactory.Instance); services.AddSingleton(); services.AddOptions(); services.AddRouting(); if (options != null) { services.Configure(options); } var context = new DefaultHttpContext { RequestServices = services.BuildServiceProvider(), }; return new VirtualPathContext( context, ambientValues: null, values: values, routeName: routeName); } private static RouteContext CreateRouteContext( string requestPath, ILoggerFactory loggerFactory = null, RouteOptions options = null) { if (loggerFactory == null) { loggerFactory = NullLoggerFactory.Instance; } if (options == null) { options = new RouteOptions(); } var request = new Mock(MockBehavior.Strict); request.SetupGet(r => r.Path).Returns(requestPath); var optionsAccessor = new Mock>(MockBehavior.Strict); optionsAccessor.SetupGet(o => o.Value).Returns(options); var context = new Mock(MockBehavior.Strict); context.Setup(m => m.RequestServices.GetService(typeof(ILoggerFactory))) .Returns(loggerFactory); context.Setup(m => m.RequestServices.GetService(typeof(IOptions))) .Returns(optionsAccessor.Object); context.SetupGet(c => c.Request).Returns(request.Object); return new RouteContext(context.Object); } private static Mock CreateRoute( bool accept = true, bool match = false, string matchValue = "value") { var target = new Mock(MockBehavior.Strict); target .Setup(e => e.GetVirtualPath(It.IsAny())) .Returns(accept || match ? new VirtualPathData(target.Object, matchValue) : null) .Verifiable(); target .Setup(e => e.RouteAsync(It.IsAny())) .Callback((c) => c.Handler = accept ? NullHandler : null) .Returns(Task.FromResult(null)) .Verifiable(); return target; } private static Action GetRouteOptions( bool lowerCaseUrls = false, bool appendTrailingSlash = false, bool lowercaseQueryStrings = false) { return (options) => { options.LowercaseUrls = lowerCaseUrls; options.AppendTrailingSlash = appendTrailingSlash; options.LowercaseQueryStrings = lowercaseQueryStrings; }; } } }