diff --git a/Core/Resgrid.Config/MappingConfig.cs b/Core/Resgrid.Config/MappingConfig.cs index 99ef8ab02..bcd36a064 100644 --- a/Core/Resgrid.Config/MappingConfig.cs +++ b/Core/Resgrid.Config/MappingConfig.cs @@ -112,7 +112,11 @@ public static class MappingConfig /// Routing provider: "valhalla", "mapbox", "google", "osrm" or "estimate" (straight line). Today's behaviour is "google". public static string RoutingProvider = "google"; - /// Provider used when the primary fails or times out; the straight-line estimate always remains the last resort. + /// + /// Provider used when the primary fails or times out; the straight-line estimate always remains the last resort. When + /// Valhalla is the primary or the fallback, Mapbox (MapboxRoutingAccessToken, else the web map's Mapbox token) is always + /// tried before the estimate. + /// public static string RoutingFallbackProvider = "estimate"; /// Base URL of a Valhalla server, e.g. http://valhalla:8002 (private network addresses are fine). @@ -132,8 +136,8 @@ public static class MappingConfig /// /// Where the Valhalla server has map data, as "west,south,east,north" boxes in degrees separated by ";". A route, - /// matrix or optimization with any point outside every box goes straight to RoutingFallbackProvider without calling - /// Valhalla. Empty: everywhere. North America and Europe (e.g. SimpleRouting): "-170,14,-50,84;-32,27,45,82". + /// matrix or optimization with any point outside every box goes straight to RoutingFallbackProvider (then Mapbox) without + /// calling Valhalla. Empty: everywhere. North America and Europe (e.g. SimpleRouting): "-170,14,-50,84;-32,27,45,82". /// public static string ValhallaCoverage = ""; @@ -143,7 +147,11 @@ public static class MappingConfig /// Most road-closure points in one Valhalla route; match the server's service_limits.max_exclude_locations (stock Valhalla 50, the setup kit raises it to 200). public static int ValhallaMaxExcludePoints = 50; - /// Server-side Mapbox token for Directions, Matrix and Optimization; may be a secret (sk.) token and is never sent to clients. + /// + /// Server-side Mapbox token for Directions, Matrix and Optimization; may be a secret (sk.) token and is never sent to + /// clients. Empty: the web app's Mapbox map token (WebsiteMapboxAccessToken, then WebsiteMapboxKey) is used, sent with + /// SystemBehaviorConfig.ResgridBaseUrl as the Referer so a URL-restricted public token is accepted. + /// public static string MapboxRoutingAccessToken = ""; /// Use live traffic where the provider has it (Mapbox driving-traffic, Google TRAFFIC_AWARE) for the DrivingTraffic profile and drive times. diff --git a/Core/Resgrid.Services/RoutingService.cs b/Core/Resgrid.Services/RoutingService.cs index 974603862..690867dd1 100644 --- a/Core/Resgrid.Services/RoutingService.cs +++ b/Core/Resgrid.Services/RoutingService.cs @@ -19,7 +19,7 @@ namespace Resgrid.Services /// /// Routes, matrices and stop-order optimization through the install's routing provider /// (maplibre-mapping-routing-navigation-plan.md §6.1): the provider MappingConfig.RoutingProvider names, then - /// RoutingFallbackProvider, then the straight-line estimate. Each call is bounded by RoutingTimeoutMs; results are + /// RoutingFallbackProvider, then Mapbox when Valhalla is either of those, then the straight-line estimate. Each call is bounded by RoutingTimeoutMs; results are /// cached in Redis by provider, profile, vehicle and coordinates (30 days, 5 minutes when traffic-aware); a provider /// that just failed is skipped for a short back-off so a broken key or server does not slow every request. /// A department with its own routing account (setting 125) has it tried first, then the system chain. Back-off, cache @@ -179,8 +179,8 @@ private IRoutingProvider Find(string name) } /// - /// The department's own account (if any), then primary, fallback, estimate — skipping duplicates, unconfigured - /// providers and ones in back-off. + /// The department's own account (if any), then primary, fallback, Mapbox when Valhalla is one of those, estimate — + /// skipping duplicates, unconfigured providers and ones in back-off. /// private async Task> ChainAsync(int departmentId) { @@ -200,19 +200,65 @@ void Add(Link link) Link System(IRoutingProvider provider) => provider == null ? null : new Link { Provider = provider, Key = provider.Name }; - Add(await DepartmentLinkAsync(departmentId)); - Add(System(Find(MappingConfig.RoutingProvider))); - Add(System(Find(MappingConfig.RoutingFallbackProvider))); + // The estimate always answers last, even when RoutingProvider or RoutingFallbackProvider names it. + Link Routed(string name) + { + var provider = Find(name); + return provider == null || provider.Name == RoutingProviderNames.Estimate ? null : System(provider); + } + + var department = await DepartmentLinkAsync(departmentId); + var primary = Routed(MappingConfig.RoutingProvider); + var fallback = Routed(MappingConfig.RoutingFallbackProvider); + + Add(department); + Add(primary); + Add(fallback); + + // Valhalla (a hosted plan's per-second and daily limits, a server that is down, a point off its map) hands over to + // the install's Mapbox before the straight-line estimate, whatever RoutingFallbackProvider says. Decided on what is + // configured, not on what is in the chain: while Valhalla sits out its back-off Mapbox must still answer. + if (UsesValhalla(department, primary, fallback)) + Add(System(Find(RoutingProviderNames.Mapbox))); + Add(System(Estimate)); return chain; } + private static bool UsesValhalla(params Link[] links) => + links.Any(l => l?.Provider != null && l.Provider.Name == RoutingProviderNames.Valhalla && l.Provider.IsConfigured); + + /// What answers after in this request's chain, for the log. + private static string Next(IReadOnlyList chain, Link link) + { + var rest = (chain ?? new List()).SkipWhile(l => !ReferenceEquals(l, link)).Skip(1) + .Select(l => l.Provider.Name == RoutingProviderNames.Estimate ? "the straight-line estimate" : l.DepartmentAccount ? $"{l.Provider.Name} (department account)" : l.Provider.Name) + .ToList(); + + return rest.Count == 0 ? "no other provider" : string.Join(" then ", rest); + } + + /// + /// Valhalla without a usable system Mapbox goes straight to the estimate when it fails: say how to close that gap. + /// + private string MapboxGap(Link link) + { + if (link?.Provider?.Name != RoutingProviderNames.Valhalla) + return string.Empty; + + var mapbox = Find(RoutingProviderNames.Mapbox); + return mapbox != null && mapbox.IsConfigured + ? string.Empty + : " Set MappingConfig.MapboxRoutingAccessToken (or a Mapbox WebsiteMapboxAccessToken) so Mapbox answers before the straight-line estimate."; + } + public async Task GetRouteAsync(RouteRequest request, CancellationToken cancellationToken = default) { if (request?.Waypoints == null || request.Waypoints.Count < 2) return new RouteResult { Error = "A route needs at least two points" }; - foreach (var link in await ChainAsync(request.DepartmentId)) + var chain = await ChainAsync(request.DepartmentId); + foreach (var link in chain) { var provider = link.Provider; var trafficAware = IsTrafficAware(provider, request.Profile); @@ -267,9 +313,9 @@ public async Task GetRouteAsync(RouteRequest request, CancellationT } if (result?.RequestRejected == true) - RecordRejection(link, result.Error); + RecordRejection(chain, link, result.Error); else - RecordFailure(link, result?.Error); + RecordFailure(chain, link, result?.Error); } return new RouteResult { Error = "No routing provider returned a route" }; @@ -305,7 +351,8 @@ public async Task GetMatrixAsync(RouteMatrixRequest request, var pending = Enumerable.Range(0, origins).ToList(); string provided = null; - foreach (var link in await ChainAsync(request.DepartmentId)) + var chain = await ChainAsync(request.DepartmentId); + foreach (var link in chain) { var provider = link.Provider; var trafficAware = IsTrafficAware(provider, request.Profile); @@ -378,9 +425,9 @@ public async Task GetMatrixAsync(RouteMatrixRequest request, // One origin outside the provider's coverage fails its whole batch; the next provider takes the // remaining origins for this request only. if (result?.RequestRejected == true) - RecordRejection(link, result.Error); + RecordRejection(chain, link, result.Error); else - RecordFailure(link, result?.Error); + RecordFailure(chain, link, result?.Error); failed = true; break; } @@ -419,7 +466,8 @@ public async Task OptimizeAsync(RouteOptimizationReques // Each provider in the chain that can optimize this many stops gets its turn (a department account that can't, or // that fails, hands over to the install's providers, as routes do); the matrix heuristic answers when none does. - foreach (var link in (await ChainAsync(request.DepartmentId)).Where(l => l.Provider.Name != RoutingProviderNames.Estimate)) + var chain = await ChainAsync(request.DepartmentId); + foreach (var link in chain.Where(l => l.Provider.Name != RoutingProviderNames.Estimate)) { var provider = link.Provider; if (!provider.Capabilities.Optimize || request.Waypoints.Count > provider.Capabilities.MaxOptimizeWaypoints) @@ -444,7 +492,7 @@ public async Task OptimizeAsync(RouteOptimizationReques catch (Exception ex) { await RecordUsageAsync(link, RoutingUsageOperation.Optimize, false, false, false); - RecordFailure(link, ex.Message); + RecordFailure(chain, link, ex.Message); } } @@ -494,7 +542,7 @@ private static bool IsTrafficAware(IRoutingProvider provider, RoutingProfile pro return profile == RoutingProfile.DrivingTraffic && MappingConfig.RoutingTrafficAware && provider.Capabilities.Traffic; } - private static void RecordFailure(Link link, string error) + private void RecordFailure(IReadOnlyList chain, Link link, string error) { var provider = link?.Provider; if (provider == null || provider.Name == RoutingProviderNames.Estimate) @@ -515,14 +563,14 @@ private static void RecordFailure(Link link, string error) return; } - Logging.LogError($"Routing provider '{provider.Name}' failed: {error}. Check MappingConfig.RoutingProvider and the provider's settings ({SettingsFor(provider.Name)}); falling back to {MappingConfig.RoutingFallbackProvider} then the straight-line estimate."); + Logging.LogError($"Routing provider '{provider.Name}' failed: {error}. Check MappingConfig.RoutingProvider and the provider's settings ({SettingsFor(provider.Name)}); falling back to {Next(chain, link)}.{MapboxGap(link)}"); } /// /// The provider is up but cannot route this request (outside its coverage, no road near a point, over its limits): /// the next provider answers it and the provider stays in use for everything else. Logged at most every 10 minutes. /// - private static void RecordRejection(Link link, string error) + private void RecordRejection(IReadOnlyList chain, Link link, string error) { var provider = link?.Provider; if (provider == null || provider.Name == RoutingProviderNames.Estimate) @@ -534,7 +582,7 @@ private static void RecordRejection(Link link, string error) return; LastErrorLogged[key] = now; - Logging.LogInfo($"Routing provider '{provider.Name}' could not route a request ({error}); {MappingConfig.RoutingFallbackProvider} answered it instead. The provider stays in use for other requests."); + Logging.LogInfo($"Routing provider '{provider.Name}' could not route a request ({error}); {Next(chain, link)} answered it instead. The provider stays in use for other requests.{MapboxGap(link)}"); } private static string SettingsFor(string provider) @@ -544,7 +592,7 @@ private static string SettingsFor(string provider) case RoutingProviderNames.Valhalla: return "MappingConfig.ValhallaUrl, MappingConfig.ValhallaApiKey, MappingConfig.ValhallaAuthScheme"; case RoutingProviderNames.Mapbox: - return "MappingConfig.MapboxRoutingAccessToken"; + return "MappingConfig.MapboxRoutingAccessToken, else MappingConfig.WebsiteMapboxAccessToken / WebsiteMapboxKey sent with SystemBehaviorConfig.ResgridBaseUrl as Referer"; case RoutingProviderNames.Google: return "MappingConfig.GoogleMapsApiKey, MappingConfig.GoogleRoutingApi"; case RoutingProviderNames.Osrm: diff --git a/Providers/Resgrid.Providers.Geo/Routing/MapboxRoutingProvider.cs b/Providers/Resgrid.Providers.Geo/Routing/MapboxRoutingProvider.cs index 89a56b1a7..02f43a96b 100644 --- a/Providers/Resgrid.Providers.Geo/Routing/MapboxRoutingProvider.cs +++ b/Providers/Resgrid.Providers.Geo/Routing/MapboxRoutingProvider.cs @@ -3,6 +3,7 @@ using System.Globalization; using System.Linq; using System.Net; +using System.Net.Http; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -14,8 +15,9 @@ namespace Resgrid.Providers.GeoLocationProvider.Routing { /// /// Mapbox Directions v5, Matrix v1 and Optimization v1 (plan §6.1) with the server-side token - /// (MappingConfig.MapboxRoutingAccessToken). Live traffic and incidents on driving-traffic. Applies vehicle height, - /// width and weight (max_height/max_width/max_weight); Mapbox has no length, axle or hazmat restrictions. + /// (MappingConfig.MapboxRoutingAccessToken), else the web app's Mapbox map token. Live traffic and incidents on + /// driving-traffic. Applies vehicle height, width and weight (max_height/max_width/max_weight); Mapbox has no length, + /// axle or hazmat restrictions. /// public class MapboxRoutingProvider : IRoutingProvider { @@ -39,7 +41,39 @@ public IRoutingProvider WithCredentials(RoutingProviderCredentials credentials) return credentials == null ? null : new MapboxRoutingProvider(credentials); } - private string AccessToken => _department != null ? _department.ApiKey : MappingConfig.MapboxRoutingAccessToken; + private string AccessToken => _department != null ? _department.ApiKey : InstallToken().Token; + + /// + /// The install's token for Directions, Matrix and Optimization: MapboxRoutingAccessToken, else the web app's Mapbox + /// map token (WebsiteMapboxAccessToken, then WebsiteMapboxKey), which those APIs accept as well — an install with + /// Mapbox maps routes on Mapbox without a separate routing token. Only a Mapbox token (pk. or sk.) is borrowed from the + /// web settings, since WebsiteMapboxKey may hold another tile service's key. The web map token is public and usually + /// URL-restricted to the web app, which Mapbox checks against the Referer, so it comes with the install's own web + /// address to send as one; the routing token comes with none. Token null: no Mapbox token at all. + /// + public static (string Token, Uri Referer) InstallToken() + { + var routing = MappingConfig.MapboxRoutingAccessToken?.Trim(); + if (!string.IsNullOrEmpty(routing)) + return (routing, null); + + var website = new[] { MappingConfig.WebsiteMapboxAccessToken, MappingConfig.WebsiteMapboxKey } + .Select(t => t?.Trim()) + .FirstOrDefault(t => !string.IsNullOrEmpty(t) && + (t.StartsWith("pk.", StringComparison.Ordinal) || t.StartsWith("sk.", StringComparison.Ordinal))); + if (website == null) + return (null, null); + + Uri.TryCreate(SystemBehaviorConfig.ResgridBaseUrl?.Trim().TrimEnd('/') + "/", UriKind.Absolute, out var site); + return (website, site); + } + + /// Sets the Referer the install's web map token needs; nothing for a routing or department token. + private Action Referer() + { + var referer = _department == null ? InstallToken().Referer : null; + return referer == null ? null : request => request.Headers.Referrer = referer; + } public string Name => RoutingProviderNames.Mapbox; @@ -89,7 +123,7 @@ public async Task GetRouteAsync(RouteRequest request, CancellationT AppendStopIndexes(query, request.Waypoints); var url = $"{BaseUrl}/directions/v5/mapbox/{profile}/{RoutingHttp.LonLatPath(request.Waypoints)}{query}"; - var (_, status, document) = await RoutingHttp.GetJsonAsync(url, cancellationToken); + var (_, status, document) = await RoutingHttp.GetJsonAsync(url, cancellationToken, Referer()); using (document) { @@ -129,7 +163,7 @@ public async Task GetMatrixAsync(RouteMatrixRequest request, query.Append("&sources=").Append(sources).Append("&destinations=").Append(destinations); var url = $"{BaseUrl}/directions-matrix/v1/mapbox/{profile}/{RoutingHttp.LonLatPath(coordinates)}{query}"; - var (ok, status, document) = await RoutingHttp.GetJsonAsync(url, cancellationToken); + var (ok, status, document) = await RoutingHttp.GetJsonAsync(url, cancellationToken, Referer()); using (document) { @@ -164,7 +198,7 @@ public async Task OptimizeAsync(RouteOptimizationReques query.Append("&source=first&destination=last&roundtrip=false&overview=false"); var url = $"{BaseUrl}/optimized-trips/v1/mapbox/{profile}/{RoutingHttp.LonLatPath(request.Waypoints)}{query}"; - var (ok, status, document) = await RoutingHttp.GetJsonAsync(url, cancellationToken); + var (ok, status, document) = await RoutingHttp.GetJsonAsync(url, cancellationToken, Referer()); using (document) { diff --git a/Tests/Resgrid.Tests/Services/Routing/RoutingServiceTests.cs b/Tests/Resgrid.Tests/Services/Routing/RoutingServiceTests.cs index 4b9601691..a1970fa02 100644 --- a/Tests/Resgrid.Tests/Services/Routing/RoutingServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/Routing/RoutingServiceTests.cs @@ -1,5 +1,8 @@ +using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; +using System.Reflection; using System.Threading; using System.Threading.Tasks; using FluentAssertions; @@ -34,6 +37,7 @@ public void SetUp() _cacheEnabled = SystemBehaviorConfig.CacheEnabled; SystemBehaviorConfig.CacheEnabled = false; MappingConfig.NavigationEnabled = true; + ClearProviderHealth(); } [TearDown] @@ -43,6 +47,20 @@ public void TearDown() MappingConfig.RoutingFallbackProvider = _fallback; MappingConfig.NavigationEnabled = _navigation; SystemBehaviorConfig.CacheEnabled = _cacheEnabled; + ClearProviderHealth(); + } + + /// + /// A failed provider is benched for 60 s in process-wide state; clear it so one test's failure never decides + /// another's chain. + /// + private static void ClearProviderHealth() + { + foreach (var name in new[] { "ProviderBackoffUntil", "LastErrorLogged" }) + { + var field = typeof(RoutingService).GetField(name, BindingFlags.NonPublic | BindingFlags.Static)!; + ((ConcurrentDictionary)field.GetValue(null)!).Clear(); + } } private static Mock Provider(string name, bool navigation, RouteResult route = null, bool configured = true) @@ -227,6 +245,200 @@ public async Task rejected_matrix_goes_to_the_fallback_without_benching_the_prov second.Provider.Should().Be(RoutingProviderNames.Estimate); valhalla.Verify(p => p.GetMatrixAsync(It.IsAny(), It.IsAny()), Times.Exactly(2)); } + + [Test] + public async Task failed_valhalla_falls_back_to_mapbox_before_the_estimate_even_with_an_estimate_fallback() + { + // Production (2026-11-13): SimpleRouting answered 429 and the dispatch recommendation got the straight-line estimate, + // because RoutingFallbackProvider was left at "estimate" although the install has a Mapbox routing token. + MappingConfig.RoutingProvider = RoutingProviderNames.Valhalla; + MappingConfig.RoutingFallbackProvider = RoutingProviderNames.Estimate; + var valhalla = Provider(RoutingProviderNames.Valhalla, true, new RouteResult { Successful = false, Error = "429: No route" }); + var mapbox = Provider(RoutingProviderNames.Mapbox, true, new RouteResult { Successful = true, DurationSeconds = 510 }); + var service = new RoutingService(new[] { valhalla.Object, mapbox.Object, new EstimateRoutingProvider() }, null, null, null); + + var first = await service.GetRouteAsync(TwoPoints()); + var second = await service.GetRouteAsync(TwoPoints()); + + first.Successful.Should().BeTrue(); + first.Provider.Should().Be(RoutingProviderNames.Mapbox); + first.DurationSeconds.Should().Be(510); + second.Provider.Should().Be(RoutingProviderNames.Mapbox, "Mapbox keeps answering while Valhalla sits out its back-off"); + valhalla.Verify(p => p.GetRouteAsync(It.IsAny(), It.IsAny()), Times.Once); + mapbox.Verify(p => p.GetRouteAsync(It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + + [Test] + public async Task rejected_valhalla_route_goes_to_mapbox_before_the_estimate() + { + MappingConfig.RoutingProvider = RoutingProviderNames.Valhalla; + MappingConfig.RoutingFallbackProvider = RoutingProviderNames.Estimate; + var valhalla = Provider(RoutingProviderNames.Valhalla, true, + new RouteResult { Successful = false, Error = "Outside MappingConfig.ValhallaCoverage", RequestRejected = true, NotSent = true }); + var mapbox = Provider(RoutingProviderNames.Mapbox, true, new RouteResult { Successful = true, DurationSeconds = 300 }); + var service = new RoutingService(new[] { valhalla.Object, mapbox.Object, new EstimateRoutingProvider() }, null, null, null); + + var route = await service.GetRouteAsync(TwoPoints()); + + route.Provider.Should().Be(RoutingProviderNames.Mapbox); + route.DurationSeconds.Should().Be(300); + } + + [Test] + public async Task a_configured_fallback_is_tried_before_the_added_mapbox() + { + MappingConfig.RoutingProvider = RoutingProviderNames.Valhalla; + MappingConfig.RoutingFallbackProvider = RoutingProviderNames.Osrm; + var valhalla = Provider(RoutingProviderNames.Valhalla, true); + var osrm = Provider(RoutingProviderNames.Osrm, false); + var mapbox = Provider(RoutingProviderNames.Mapbox, true, new RouteResult { Successful = true, DurationSeconds = 300 }); + var service = new RoutingService(new[] { valhalla.Object, osrm.Object, mapbox.Object, new EstimateRoutingProvider() }, null, null, null); + + var route = await service.GetRouteAsync(TwoPoints()); + + route.Provider.Should().Be(RoutingProviderNames.Mapbox); + osrm.Verify(p => p.GetRouteAsync(It.IsAny(), It.IsAny()), Times.Once); + mapbox.Verify(p => p.GetRouteAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task failed_valhalla_without_a_mapbox_token_falls_back_to_the_estimate() + { + MappingConfig.RoutingProvider = RoutingProviderNames.Valhalla; + MappingConfig.RoutingFallbackProvider = RoutingProviderNames.Estimate; + var valhalla = Provider(RoutingProviderNames.Valhalla, true, new RouteResult { Successful = false, Error = "503: unavailable" }); + var mapbox = Provider(RoutingProviderNames.Mapbox, true, new RouteResult { Successful = true }, configured: false); + var service = new RoutingService(new[] { valhalla.Object, mapbox.Object, new EstimateRoutingProvider() }, null, null, null); + + var route = await service.GetRouteAsync(TwoPoints()); + + route.Successful.Should().BeTrue(); + route.Provider.Should().Be(RoutingProviderNames.Estimate); + mapbox.Verify(p => p.GetRouteAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task mapbox_is_not_added_when_valhalla_is_not_in_use() + { + // Mapbox is billed per call: an install on Google with an estimate fallback keeps exactly that chain. + MappingConfig.RoutingProvider = RoutingProviderNames.Google; + MappingConfig.RoutingFallbackProvider = RoutingProviderNames.Estimate; + var google = Provider(RoutingProviderNames.Google, false); + var mapbox = Provider(RoutingProviderNames.Mapbox, true, new RouteResult { Successful = true }); + var service = new RoutingService(new[] { google.Object, mapbox.Object, new EstimateRoutingProvider() }, null, null, null); + + var route = await service.GetRouteAsync(TwoPoints()); + + route.Provider.Should().Be(RoutingProviderNames.Estimate); + mapbox.Verify(p => p.GetRouteAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task failed_valhalla_matrix_falls_back_to_the_mapbox_matrix() + { + MappingConfig.RoutingProvider = RoutingProviderNames.Valhalla; + MappingConfig.RoutingFallbackProvider = RoutingProviderNames.Estimate; + var valhalla = Provider(RoutingProviderNames.Valhalla, true); + valhalla.Setup(p => p.GetMatrixAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new RouteMatrixResult { Successful = false, Error = "Matrix failed (429)" }); + var mapbox = Provider(RoutingProviderNames.Mapbox, true); + mapbox.Setup(p => p.GetMatrixAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((RouteMatrixRequest r, CancellationToken _) => new RouteMatrixResult + { + Successful = true, + Provider = RoutingProviderNames.Mapbox, + Durations = r.Origins.Select(_ => r.Destinations.Select(__ => (double?)240).ToArray()).ToArray(), + Distances = r.Origins.Select(_ => r.Destinations.Select(__ => (double?)3000).ToArray()).ToArray() + }); + var service = new RoutingService(new[] { valhalla.Object, mapbox.Object, new EstimateRoutingProvider() }, null, null, null); + + var matrix = await service.GetMatrixAsync(new RouteMatrixRequest + { + Origins = new List { new RoutingWaypoint(39.53, -119.81), new RoutingWaypoint(39.60, -119.80) }, + Destinations = new List { new RoutingWaypoint(39.54, -119.75) } + }); + + matrix.Successful.Should().BeTrue(); + matrix.Provider.Should().Be(RoutingProviderNames.Mapbox); + matrix.Durations[0][0].Should().Be(240); + matrix.Durations[1][0].Should().Be(240); + } + } + + /// + /// Mapbox routing without a routing token borrows the web app's Mapbox map token (Directions, Matrix and Optimization + /// accept a public token), with the install's web address as Referer for a URL-restricted token. + /// + [TestFixture] + public class MapboxInstallTokenTests + { + private (string Routing, string WebsiteAccess, string WebsiteKey, string BaseUrl) _saved; + + [SetUp] + public void SetUp() + { + _saved = (MappingConfig.MapboxRoutingAccessToken, MappingConfig.WebsiteMapboxAccessToken, MappingConfig.WebsiteMapboxKey, SystemBehaviorConfig.ResgridBaseUrl); + MappingConfig.MapboxRoutingAccessToken = MappingConfig.WebsiteMapboxAccessToken = MappingConfig.WebsiteMapboxKey = string.Empty; + SystemBehaviorConfig.ResgridBaseUrl = "https://app.example.org"; + } + + [TearDown] + public void TearDown() + { + (MappingConfig.MapboxRoutingAccessToken, MappingConfig.WebsiteMapboxAccessToken, MappingConfig.WebsiteMapboxKey, SystemBehaviorConfig.ResgridBaseUrl) = _saved; + } + + [Test] + public void the_routing_token_wins_and_needs_no_referer() + { + MappingConfig.MapboxRoutingAccessToken = "sk.routing"; + MappingConfig.WebsiteMapboxAccessToken = "pk.website"; + + var (token, referer) = MapboxRoutingProvider.InstallToken(); + + token.Should().Be("sk.routing"); + referer.Should().BeNull(); + } + + [Test] + public void without_a_routing_token_the_web_map_token_answers_with_the_install_address_as_referer() + { + MappingConfig.WebsiteMapboxAccessToken = " pk.website "; + SystemBehaviorConfig.ResgridBaseUrl = "https://app.example.org/"; + + var (token, referer) = MapboxRoutingProvider.InstallToken(); + + token.Should().Be("pk.website"); + referer.Should().Be(new Uri("https://app.example.org/")); + new MapboxRoutingProvider().IsConfigured.Should().BeTrue(); + } + + [Test] + public void the_older_website_key_is_used_when_the_access_token_is_blank() + { + MappingConfig.WebsiteMapboxKey = "pk.older"; + + MapboxRoutingProvider.InstallToken().Token.Should().Be("pk.older"); + } + + [Test] + public void a_non_mapbox_website_key_is_never_sent_to_mapbox() + { + // WebsiteMapboxKey has held MapTiler keys on Leaflet installs; sending one to Mapbox only earns a 401 and a back-off. + MappingConfig.WebsiteMapboxKey = "maptiler-abc123"; + + MapboxRoutingProvider.InstallToken().Token.Should().BeNull(); + new MapboxRoutingProvider().IsConfigured.Should().BeFalse(); + } + + [Test] + public void a_department_account_uses_only_its_own_key() + { + MappingConfig.WebsiteMapboxAccessToken = "pk.website"; + + new MapboxRoutingProvider().WithCredentials(new RoutingProviderCredentials { Provider = RoutingProviderNames.Mapbox, ApiKey = "" }) + .IsConfigured.Should().BeFalse("a department's blank key never borrows the install's web token"); + } } [TestFixture]