mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
e11ae6f056
The road-trip addon's second half: the parts that let a planned drive be argued with rather than only read. Reshaping. A via records which stop it follows by position, and that position is resolved fresh every time the day is routed — so inserting, reordering or removing a stop moved the ground under every via after it, and the drive silently snapped back to the road it had been steered away from while the handle still sat on the map where it was left. The correction is computed against the day as it stands and written with the same change, in one transaction: a half-applied re-anchoring mixes two numbering schemes in one day. The one judgement that needs geometry — which half of a split leg a via belongs to when a stop lands in the middle of it — is measured on the road actually driven, so a fuel stop added to a bent leg keeps the bend on the side it belongs to. Alternatives. OSRM answers most long legs with exactly one route, which read as "there is no other way" when a perfectly good slower road existed. When it offers nothing, the same drive is asked for again without the motorway, and failing that without the tolls — one question at a time, stopping as soon as one lands. The offer says what it is rather than how much slower. Stops along the way. Everything the corridor search can find is a pause, not a destination: fuel, charging, rest areas, campsites, food and sights all sit inside the leg they were found on, carry their own colour and icon, and are left out of every stop count. That colour is defined once and read by the rail, the corridor list, the map pin and the picker — and it replaced the chains' logos, which turned a corridor full of petrol stations into a row of advertisements and left the brands with no logo on file looking like something else entirely. How long a stop takes was readable in the rail and writable nowhere; new places were given an invented hour that nobody had entered. It is now set from the rail, cleared as a zero because the update statement folds a null into "leave it alone", and new places start with nothing. Both columns are rebuilt on one type scale, anchored to the day plan's own sizes so switching tabs does not change the size of the text beside it. The rail reads as trip totals, day cards and a chain of stops with the drives between them; the corridor column as what to look for, how the search is getting on, and what it found. Six category pills over three lines became one dropdown, which is the space the results wanted. Also: the offered routes are framed on the map when the picker opens (`fitKey` carries no coordinates and each renderer reads it as "the selected day"), road trip mode does not cluster at all so the whole drive stays readable zoomed out, and the drive-time labels finally render in the app's own typeface instead of Leaflet's Helvetica. All of it in both renderers.
167 lines
7.1 KiB
TypeScript
167 lines
7.1 KiB
TypeScript
import { describe, it, expect, afterEach } from 'vitest';
|
|
import express from 'express';
|
|
import request from 'supertest';
|
|
import { applyGlobalMiddleware, routingCspOrigins } from '../../../src/middleware/globalMiddleware';
|
|
|
|
async function directiveSources(name: string): Promise<string[]> {
|
|
const app = express();
|
|
applyGlobalMiddleware(app);
|
|
app.get('/probe', (_req, res) => res.json({ ok: true }));
|
|
|
|
const res = await request(app).get('/probe');
|
|
const csp = String(res.headers['content-security-policy'] || '');
|
|
const directive = csp
|
|
.split(';')
|
|
.map(d => d.trim())
|
|
.find(d => d.startsWith(name));
|
|
|
|
return directive ? directive.split(/\s+/).slice(1) : [];
|
|
}
|
|
|
|
const connectSrcSources = () => directiveSources('connect-src');
|
|
|
|
describe('global CSP: OpenStreetMap tile hosts (#1733)', () => {
|
|
it('allows the bare tile.openstreetmap.org host', async () => {
|
|
// A CSP wildcard host never matches the apex, so `*.tile.openstreetmap.org`
|
|
// alone would block the tile prefetcher's fetch() against the single host
|
|
// OSM has served from since it retired a/b/c/d sharding.
|
|
expect(await connectSrcSources()).toContain('https://tile.openstreetmap.org');
|
|
});
|
|
|
|
it('still allows the sharded hosts for templates saved earlier', async () => {
|
|
expect(await connectSrcSources()).toContain('https://*.tile.openstreetmap.org');
|
|
});
|
|
});
|
|
|
|
describe('global CSP: the other shipped raster presets (#2180)', () => {
|
|
it('allows tile.openstreetmap.de', async () => {
|
|
// The prefetcher fetches tiles with mode 'no-cors', which relaxes CORS and
|
|
// nothing else: a host missing here is refused in the document, so the
|
|
// Service Worker never sees the request and caches no tile at all.
|
|
expect(await connectSrcSources()).toContain('https://tile.openstreetmap.de');
|
|
});
|
|
|
|
it('allows tiles.stadiamaps.com', async () => {
|
|
expect(await connectSrcSources()).toContain('https://tiles.stadiamaps.com');
|
|
});
|
|
|
|
it('keeps the routing host, which is a different host and covers nothing here', async () => {
|
|
// routing.openstreetmap.de was on the list all along and looks close enough
|
|
// to hide the gap: a CSP source matches a host, not a suffix of one.
|
|
expect(await connectSrcSources()).toContain('https://routing.openstreetmap.de/');
|
|
});
|
|
});
|
|
|
|
describe('global CSP: script-src', () => {
|
|
it("allows 'wasm-unsafe-eval' so the WASM decoders keep running", async () => {
|
|
expect(await directiveSources('script-src')).toContain("'wasm-unsafe-eval'");
|
|
});
|
|
|
|
it("still allows 'unsafe-eval', which heic-to needs to decode an iPhone photo", async () => {
|
|
// Pinned so the next tidy-up of this list finds the reason before the
|
|
// consequence: libheif initialises embind with new Function(), and without
|
|
// this every .heic upload fails in the browser. See the comment on the
|
|
// directive for how to actually get rid of it.
|
|
expect(await directiveSources('script-src')).toContain("'unsafe-eval'");
|
|
});
|
|
});
|
|
|
|
describe('forced-HTTPS redirect', () => {
|
|
const saved = { FORCE_HTTPS: process.env.FORCE_HTTPS, APP_URL: process.env.APP_URL };
|
|
|
|
afterEach(() => {
|
|
process.env.FORCE_HTTPS = saved.FORCE_HTTPS;
|
|
process.env.APP_URL = saved.APP_URL;
|
|
if (saved.FORCE_HTTPS === undefined) delete process.env.FORCE_HTTPS;
|
|
if (saved.APP_URL === undefined) delete process.env.APP_URL;
|
|
});
|
|
|
|
async function redirectLocation(): Promise<string> {
|
|
const app = express();
|
|
applyGlobalMiddleware(app);
|
|
app.get('/trips', (_req, res) => res.json({ ok: true }));
|
|
const res = await request(app).get('/trips').set('Host', 'evil.example.com');
|
|
return String(res.headers.location || '');
|
|
}
|
|
|
|
it('redirects to the configured APP_URL host, not the Host header the caller sent', async () => {
|
|
process.env.FORCE_HTTPS = 'true';
|
|
process.env.APP_URL = 'https://trip.pakulat.org';
|
|
expect(await redirectLocation()).toBe('https://trip.pakulat.org/trips');
|
|
});
|
|
|
|
it('falls back to the request host when APP_URL is unset', async () => {
|
|
process.env.FORCE_HTTPS = 'true';
|
|
delete process.env.APP_URL;
|
|
expect(await redirectLocation()).toBe('https://evil.example.com/trips');
|
|
});
|
|
|
|
it('falls back to the request host when APP_URL is not a URL', async () => {
|
|
// A typo in the env should not take the instance down, and it should not
|
|
// produce a redirect to a host built from a half-parsed string either.
|
|
process.env.FORCE_HTTPS = 'true';
|
|
process.env.APP_URL = 'not a url';
|
|
expect(await redirectLocation()).toBe('https://evil.example.com/trips');
|
|
});
|
|
|
|
it('leaves an already-secure request alone, and never redirects the health probe', async () => {
|
|
process.env.FORCE_HTTPS = 'true';
|
|
process.env.APP_URL = 'https://trip.pakulat.org';
|
|
const app = express();
|
|
applyGlobalMiddleware(app);
|
|
app.get('/trips', (_req, res) => res.json({ ok: true }));
|
|
app.get('/api/health', (_req, res) => res.json({ ok: true }));
|
|
|
|
// The proxy already terminated TLS, so there is nothing to upgrade.
|
|
const forwarded = await request(app).get('/trips').set('X-Forwarded-Proto', 'https');
|
|
expect(forwarded.status).toBe(200);
|
|
|
|
// The container probe talks plain HTTP on the loopback and must not be
|
|
// bounced to a hostname it cannot resolve.
|
|
const probe = await request(app).get('/api/health');
|
|
expect(probe.status).toBe(200);
|
|
});
|
|
});
|
|
|
|
describe('routingCspOrigins', () => {
|
|
it('CSP-ROUTING-001: keeps only the origin, because a path source matches that path alone', () => {
|
|
// A router is asked at several paths (/route/v1/…, /table/v1/…); pinning one would
|
|
// block the rest without an error the app could report.
|
|
expect(routingCspOrigins(['https://osrm.example.org/route/v1/driving']))
|
|
.toEqual(['https://osrm.example.org']);
|
|
});
|
|
|
|
it('CSP-ROUTING-002: a port belongs to the origin and is kept', () => {
|
|
expect(routingCspOrigins(['http://192.168.178.72:5000'])).toEqual(['http://192.168.178.72:5000']);
|
|
});
|
|
|
|
it('CSP-ROUTING-003: anything that is not an http(s) URL widens nothing', () => {
|
|
// A bad settings row must not be able to loosen the policy.
|
|
expect(routingCspOrigins(['', null, undefined, 'not a url', 'javascript:alert(1)', 'ftp://x/y']))
|
|
.toEqual([]);
|
|
});
|
|
|
|
it('CSP-ROUTING-004: the same host twice is one source', () => {
|
|
expect(routingCspOrigins([
|
|
'https://osrm.example.org/route/v1/driving',
|
|
'https://osrm.example.org/table/v1/driving',
|
|
])).toEqual(['https://osrm.example.org']);
|
|
});
|
|
});
|
|
|
|
describe('connect-src with a self-hosted router', () => {
|
|
it('CSP-ROUTING-005: a configured origin is named, or the browser blocks it silently', async () => {
|
|
const app = express();
|
|
applyGlobalMiddleware(app, { extraConnectSrc: ['https://osrm.example.org'] });
|
|
app.get('/probe', (_req, res) => res.json({ ok: true }));
|
|
|
|
const res = await request(app).get('/probe');
|
|
const csp = String(res.headers['content-security-policy'] || '');
|
|
const connect = csp.split(';').map(d => d.trim()).find(d => d.startsWith('connect-src'))!;
|
|
|
|
expect(connect).toContain('https://osrm.example.org');
|
|
// The public hosts stay, so an instance can be switched back without another deploy.
|
|
expect(connect).toContain('https://routing.openstreetmap.de/');
|
|
});
|
|
});
|