mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
371b7da9de
A day is routed as one request over all of its stops, and OSRM's car profile forbids a u-turn at an intermediate waypoint unless asked. When it cannot obey it refuses the whole request rather than the one leg, so a single stop on a dead end took every leg of the day with it. Gouffre de Padirac snaps onto a cave access road that goes nowhere else: each of that day's pairs routes fine alone, all five together come back 400 NoRoute, and the same five with continue_straight=false come back with their legs. The permission goes on every request that has a stop in the middle, in the browser and in the server's planning path, which had the same defect. Two-point requests keep the url they had. OSRM refuses an unknown query parameter outright rather than ignoring it, so a router that has never heard of this one is recognised by its InvalidQuery, asked again without it, and remembered per origin.
190 lines
7.5 KiB
TypeScript
190 lines
7.5 KiB
TypeScript
import { RoadtripRouterService } from '../../../src/nest/roadtrip/roadtrip-router.service';
|
|
import { safeFetchAdminConfigured } from '../../../src/utils/ssrfGuard';
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
vi.mock('../../../src/utils/ssrfGuard', () => ({ safeFetchAdminConfigured: vi.fn() }));
|
|
vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled: () => true }));
|
|
const points = [
|
|
{ lat: 48, lng: 10 },
|
|
{ lat: 49, lng: 11 },
|
|
{ lat: 50, lng: 12 },
|
|
];
|
|
const osrm = () =>
|
|
new Response(
|
|
JSON.stringify({
|
|
code: 'Ok',
|
|
routes: [
|
|
{
|
|
distance: 3000,
|
|
duration: 300,
|
|
geometry: {
|
|
coordinates: [
|
|
[10, 48],
|
|
[11, 49],
|
|
[12, 50],
|
|
],
|
|
},
|
|
legs: [
|
|
{ distance: 1000, duration: 100 },
|
|
{ distance: 2000, duration: 200 },
|
|
],
|
|
},
|
|
],
|
|
waypoints: points.map((p) => ({ location: [p.lng, p.lat], distance: 25 })),
|
|
}),
|
|
);
|
|
function setup(settings = {}) {
|
|
const hooks = { providersOf: vi.fn(() => ['scenic']), route: vi.fn() };
|
|
const db = {
|
|
connection: {
|
|
prepare: () => ({ get: () => ({ capabilities: JSON.stringify({ routeProfiles: [{ id: 'car' }] }) }) }),
|
|
},
|
|
};
|
|
return {
|
|
hooks,
|
|
router: new RoadtripRouterService({ getUserSettings: () => settings } as never, hooks as never, db as never),
|
|
};
|
|
}
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
vi.mocked(safeFetchAdminConfigured)
|
|
.mockReset()
|
|
.mockImplementation(async () => osrm());
|
|
});
|
|
afterEach(() => vi.useRealTimers());
|
|
|
|
describe('server Roadtrip router', () => {
|
|
it('reads Valhalla leg times, geometry and unmet road preferences', async () => {
|
|
const encode = (value: number) => {
|
|
let remaining = value < 0 ? ~(value << 1) : value << 1;
|
|
let encoded = '';
|
|
while (remaining >= 32) { encoded += String.fromCharCode((32 | (remaining & 31)) + 63); remaining >>>= 5; }
|
|
return encoded + String.fromCharCode(remaining + 63);
|
|
};
|
|
const shape = encode(48000000) + encode(10000000) + encode(1000000) + encode(1000000);
|
|
vi.mocked(safeFetchAdminConfigured).mockResolvedValueOnce(new Response(JSON.stringify({trip:{legs:[{shape,summary:{length:25,time:1200,has_toll:true,has_ferry:false}}]}})));
|
|
const route = await setup().router.route(1,1,1,points.slice(0,2),'driving',['toll','ferry']);
|
|
expect(route.leg.line).toEqual([[48,10],[49,11]]);
|
|
expect(route.parts).toEqual([{distance:25000,duration:1200}]);
|
|
expect(route.avoidMissed).toEqual(['toll']);
|
|
expect(route.snapped).toHaveLength(2);
|
|
expect(safeFetchAdminConfigured).toHaveBeenCalledTimes(1);
|
|
});
|
|
it('uses bounded SSRF-checked requests, keeps every snap and caches per user', async () => {
|
|
const { router } = setup();
|
|
const route = await router.route(1, 1, 1, points, 'driving', []);
|
|
expect(route.parts.map((p) => p.duration)).toEqual([100, 200]);
|
|
expect(route.snapped).toHaveLength(3);
|
|
expect(route.snapped?.[1].meters).toBe(25);
|
|
expect(safeFetchAdminConfigured).toHaveBeenCalledWith(
|
|
expect.stringContaining('routing.openstreetmap.de/routed-car'),
|
|
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
|
);
|
|
await router.route(1, 1, 1, points, 'driving', []);
|
|
expect(safeFetchAdminConfigured).toHaveBeenCalledTimes(1);
|
|
const other = router.route(2, 1, 1, points, 'driving', []);
|
|
await vi.runAllTimersAsync();
|
|
await other;
|
|
expect(safeFetchAdminConfigured).toHaveBeenCalledTimes(2);
|
|
});
|
|
it('keeps a private OSRM instance private and reports unsupported avoidance', async () => {
|
|
const { router } = setup({ routing_base_url: 'http://router.internal/' });
|
|
const route = await router.route(1, 1, 1, points, 'driving', ['toll']);
|
|
expect(safeFetchAdminConfigured).toHaveBeenCalledTimes(1);
|
|
expect(safeFetchAdminConfigured).toHaveBeenCalledWith(
|
|
expect.stringMatching(/^http:\/\/router.internal\/route/),
|
|
expect.anything(),
|
|
);
|
|
expect(route.avoidMissed).toEqual(['toll']);
|
|
});
|
|
it('falls back from failed Valhalla without claiming avoidance succeeded', async () => {
|
|
vi.mocked(safeFetchAdminConfigured).mockResolvedValueOnce(new Response('', { status: 503 }));
|
|
const pending = setup().router.route(1, 1, 1, points, 'driving', ['ferry']);
|
|
await vi.runAllTimersAsync();
|
|
expect((await pending).avoidMissed).toEqual(['ferry']);
|
|
expect(safeFetchAdminConfigured).toHaveBeenCalledTimes(2);
|
|
});
|
|
it('lets a drive turn round at a stop it passes through, and asks nothing extra of a single leg', async () => {
|
|
// OSRM's car profile forbids a u-turn at an INTERMEDIATE waypoint unless asked, and
|
|
// refuses the WHOLE request when it cannot obey: one stop on a dead end used to
|
|
// cost a routed day all of its legs at once.
|
|
const { router } = setup();
|
|
await router.route(1, 1, 1, points, 'driving', []);
|
|
expect(safeFetchAdminConfigured).toHaveBeenCalledWith(
|
|
expect.stringContaining('continue_straight=false'),
|
|
expect.anything(),
|
|
);
|
|
|
|
vi.mocked(safeFetchAdminConfigured).mockResolvedValueOnce(
|
|
new Response(JSON.stringify({
|
|
code: 'Ok',
|
|
routes: [{ distance: 1000, duration: 100, geometry: { coordinates: [[10, 48], [11, 49]] }, legs: [{ distance: 1000, duration: 100 }] }],
|
|
})),
|
|
);
|
|
const pair = router.route(2, 1, 1, points.slice(0, 2), 'driving', []);
|
|
await vi.runAllTimersAsync();
|
|
await pair;
|
|
expect(safeFetchAdminConfigured).toHaveBeenLastCalledWith(
|
|
expect.not.stringContaining('continue_straight'),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
it('does not apply car avoidance to walking routes', async () => {
|
|
const route = await setup().router.route(1, 1, 1, points, 'walking', ['toll']);
|
|
expect(route.avoidMissed).toEqual([]);
|
|
expect(safeFetchAdminConfigured).toHaveBeenCalledWith(
|
|
expect.stringContaining('routed-foot/route/v1/foot/'),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
it.each([
|
|
{ code: 'NoRoute' },
|
|
{
|
|
code: 'Ok',
|
|
routes: [
|
|
{
|
|
distance: 1,
|
|
duration: 1,
|
|
legs: [],
|
|
geometry: {
|
|
coordinates: [
|
|
[999, 0],
|
|
[0, 0],
|
|
],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
])('rejects malformed provider answers', async (body) => {
|
|
vi.mocked(safeFetchAdminConfigured).mockResolvedValueOnce(new Response(JSON.stringify(body)));
|
|
await expect(setup().router.route(1, 1, 1, points, 'driving', [])).rejects.toThrow();
|
|
});
|
|
it('requires a granted plugin and a declared profile, without public fallback', async () => {
|
|
const { router, hooks } = setup();
|
|
await expect(router.route(1, 1, 1, points, 'plugin:scenic/missing', [])).rejects.toThrow();
|
|
expect(hooks.route).not.toHaveBeenCalled();
|
|
expect(safeFetchAdminConfigured).not.toHaveBeenCalled();
|
|
});
|
|
it('normalizes plugin output under the requesting user and trip', async () => {
|
|
const { router, hooks } = setup();
|
|
hooks.route.mockResolvedValue({
|
|
coordinates: [
|
|
[48, 10],
|
|
[49, 11],
|
|
[50, 12],
|
|
],
|
|
distance: 3000,
|
|
duration: 300,
|
|
legs: [
|
|
{ distance: 1000, duration: 100 },
|
|
{ distance: 2000, duration: 200 },
|
|
],
|
|
viaPoints: [],
|
|
});
|
|
expect((await router.route(7, 8, 9, points, 'plugin:scenic/car', [])).leg.seg.distance).toBe(3000);
|
|
expect(hooks.route).toHaveBeenCalledWith('scenic', { tripId: 8, dayId: 9, profile: 'car', waypoints: points }, 7);
|
|
expect(router.profiles()).toContain('plugin:scenic/car');
|
|
});
|
|
});
|