Files
gods-eye-view/src/tooling/placeProviders.test.mjs
T
Sameh KhamisandClaude Opus 5.5 9b4ccbff62 fix(server): gate Google text search against cross-site requests
/api/google/text-search spends the same Google quota as nearby-places but
was not behind the cross-site gate. It now refuses cross-site requests
before calling Google, and a test covers both paid Google routes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-02 20:16:39 -07:00

546 lines
17 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
googlePlacesContextProxy,
installRouteMiddleware,
} from '../../server/providers/places.js';
import {
ROUTE_UPSTREAM_MIN_INTERVAL_MS,
ROUTE_UPSTREAM_QUEUE_MAX,
_resetRouteUpstreamForTest,
_routeInflightCountForTest,
_setRouteUpstreamIntervalForTest,
} from '../../server/providers/places/routes.js';
import { ROUTE_STEPS_MAX } from '../../src/data/routeSteps.js';
import {
projectNearbyPlaces,
projectTextSearchPlaces,
} from '../../src/data/placeProviderPayloads.js';
function install(register) {
const routes = new Map();
register({
use(route, handler) {
routes.set(route, handler);
},
});
return async (route, url, method = 'GET', peer = 'fixture') => {
const res = {
statusCode: 200,
headers: {},
setHeader(name, value) {
this.headers[name.toLowerCase()] = value;
},
writeHead(status, headers) {
this.statusCode = status;
for (const [key, value] of Object.entries(headers))
this.setHeader(key, value);
},
end(body) {
this.body = JSON.parse(body);
},
};
await routes.get(route)(
{ url, method, socket: { remoteAddress: peer } },
res,
);
return res;
};
}
test('nearby labels rank landmarks, deduplicate names/addresses and bound the projection', () => {
const place = (name, types, longitude = 0) => ({
displayName: { text: name },
types,
location: { latitude: 0, longitude },
formattedAddress: 'Street',
});
const result = projectNearbyPlaces(
{
places: [
place('Bathroom', ['public_bathroom']),
place('Monument', ['monument'], 0.1),
place('MONUMENT', ['monument']),
...Array.from({ length: 25 }, (_, i) =>
place(`POI ${i}`, ['point_of_interest'], i / 100),
),
{ id: 'unnamed' },
],
},
0,
0,
);
assert.equal(result.length, 20);
assert.equal(result[0].name, 'Monument');
assert.equal(result[0].distanceM, 11132);
assert.equal(
result.filter((p) => p.name.toLowerCase() === 'monument').length,
1,
);
assert.ok(result.every((p) => !('contextPriority' in p)));
assert.deepEqual(projectNearbyPlaces({}, 0, 0), []);
});
test('text search preserves bounds, rejects malformed bounds and tolerates absent locations', () => {
const viewport = {
low: { latitude: 1, longitude: 2 },
high: { latitude: 3, longitude: 4 },
};
const result = projectTextSearchPlaces(
{
places: [
{
displayName: { text: 'Museum' },
viewport,
types: Array(12).fill('museum'),
},
{ displayName: { text: 'Park' }, viewport: { low: viewport.low } },
],
},
0,
0,
);
assert.deepEqual(result[0].viewport, viewport);
assert.equal(result[0].distanceM, Number.MAX_SAFE_INTEGER);
assert.equal(result[0].latitude, null);
assert.equal(result[0].types.length, 8);
assert.equal(result[1].viewport, null);
});
for (const preview of [false, true]) {
test(`Google middleware resolves credentials per request and preserves search responses (${preview ? 'preview' : 'dev'})`, async (t) => {
let key = '';
const plugin = googlePlacesContextProxy({ resolveApiKey: () => key });
const request = install((middlewares) =>
plugin[preview ? 'configurePreviewServer' : 'configureServer']({
middlewares,
}),
);
const calls = [];
t.mock.method(globalThis, 'fetch', async (url, options) => {
calls.push({ url, options });
assert.equal(options.headers['X-Goog-Api-Key'], key);
return Response.json({
places: [
{
displayName: { text: 'Museum' },
location: { latitude: 30, longitude: -97 },
},
],
});
});
const nearby = '/api/google/nearby-places';
const search = '/api/google/text-search';
assert.equal(
(await request(nearby, '?lat=30&lon=-97')).body.configured,
false,
);
assert.equal(calls.length, 0);
key = 'fixture-server-key';
const result = await request(nearby, '?lat=30&lon=-97&radiusM=9000');
assert.equal(result.statusCode, 200);
assert.equal(result.body.places[0].name, 'Museum');
assert.equal(
JSON.parse(calls[0].options.body).locationRestriction.circle.radius,
5000,
);
assert.equal(result.headers['cache-control'], 'private, max-age=300');
key = 'rotated-fixture-key';
assert.equal(
(await request(search, '?q=museum&lat=30&lon=-97&radiusM=1')).statusCode,
200,
);
assert.equal(
JSON.parse(calls[1].options.body).locationBias.circle.radius,
50,
);
assert.equal((await request(search, '?lat=30&lon=-97')).statusCode, 400);
assert.equal((await request(nearby, '?lat=bad&lon=-97')).statusCode, 400);
assert.equal((await request(nearby, '', 'POST')).statusCode, 405);
t.mock.method(globalThis, 'fetch', async () =>
Response.json({ error: { message: 'Denied' } }, { status: 403 }),
);
assert.deepEqual((await request(search, '?q=museum&lat=30&lon=-97')).body, {
places: [],
error: 'Denied',
});
});
}
test('OSRM routing preserves aliases, cache, span guards and upstream failure behavior', async (t) => {
const request = install(installRouteMiddleware);
let calls = 0;
let now = Date.now();
t.mock.method(Date, 'now', () => now);
t.mock.method(globalThis, 'fetch', async (url) => {
calls++;
assert.match(url, /routed-car\/route\/v1\/driving\//);
return Response.json({
code: 'Ok',
routes: [
{
distance: 123.6,
duration: 80.2,
geometry: {
coordinates: [
[-97, 30],
[-97.01, 30.01],
],
},
},
],
});
});
const route = '/api/route';
const query = '?profile=driving&coords=-97,30;-97.01,30.01';
const result = await request(route, query);
assert.deepEqual(result.body, {
ok: true,
profile: 'car',
distanceM: 124,
durationS: 80,
geometry: [
[-97, 30],
[-97.01, 30.01],
],
});
assert.deepEqual(
(await request(route, query.replace('driving', 'car'))).body,
result.body,
);
assert.equal(calls, 1);
for (const [url, error] of [
['?profile=plane&coords=0,0;1,1', 'invalid profile'],
['?coords=0,91;1,1', 'invalid coordinate'],
['?coords=0,0;100,0', 'route leg too long'],
['?coords=0,0', 'need 2-12 coordinates'],
])
assert.equal((await request(route, url)).body.error, error);
assert.equal(calls, 1);
now += 600001;
t.mock.method(
globalThis,
'fetch',
async () => new Response('offline', { status: 503 }),
);
assert.deepEqual((await request(route, query)).body, {
ok: false,
error: 'no route found',
});
t.mock.method(
globalThis,
'fetch',
async () => new Response('x', { headers: { 'content-type': 'text/html' } }),
);
assert.equal((await request(route, query)).body.error, 'no route found');
});
/** One OSRM route with `count` maneuvers, for the step tests. */
function osrmRouteWithSteps(count) {
return {
code: 'Ok',
routes: [
{
distance: 123.6,
duration: 80.2,
geometry: {
coordinates: [
[-97, 30],
[-97.01, 30.01],
],
},
legs: [
{
steps: Array.from({ length: count }, (_, i) => ({
name: `Road ${i}`,
distance: 10,
duration: 5,
maneuver: {
type: i === 0 ? 'depart' : 'turn',
modifier: 'left',
location: [-97 + i / 1000, 30],
},
})),
},
],
},
],
};
}
test('steps are opt-in: the upstream is only asked for them when a caller is', async (t) => {
_resetRouteUpstreamForTest();
const request = install(installRouteMiddleware);
const asked = [];
t.mock.method(globalThis, 'fetch', async (url) => {
asked.push(String(url));
return Response.json(osrmRouteWithSteps(3));
});
const route = '/api/route';
const query = '?profile=car&coords=-97,30;-97.01,30.01';
// The voice route annotation and fly_route do not read maneuvers. What they
// get back must be exactly what this endpoint returned before steps existed.
const plain = await request(route, query);
assert.deepEqual(plain.body, {
ok: true,
profile: 'car',
distanceM: 124,
durationS: 80,
geometry: [
[-97, 30],
[-97.01, 30.01],
],
});
assert.equal('steps' in plain.body, false, 'no steps key at all');
assert.match(asked[0], /steps=false/, 'the upstream payload stays small');
// Directions asks, and gets them.
_resetRouteUpstreamForTest();
const withSteps = await request(route, `${query}&steps=1`);
assert.equal(asked.length, 2);
assert.match(asked[1], /steps=true/);
assert.equal(withSteps.body.steps.length, 3);
assert.equal(withSteps.body.steps[0].instruction, 'Head out on Road 0');
assert.equal(withSteps.body.steps[1].instruction, 'Turn left onto Road 1');
// A stepful cache entry still serves a stepless caller its own shape.
const reuse = await request(route, query);
assert.equal(asked.length, 2, 'served from cache');
assert.deepEqual(
reuse.body,
plain.body,
'byte-identical to the no-steps shape',
);
});
test('identical concurrent route requests make one upstream call', async (t) => {
_resetRouteUpstreamForTest();
const request = install(installRouteMiddleware);
let calls = 0;
let release;
const gate = new Promise((resolve) => {
release = resolve;
});
t.mock.method(globalThis, 'fetch', async () => {
calls += 1;
await gate;
return Response.json(osrmRouteWithSteps(4));
});
const route = '/api/route';
const query = '?profile=car&coords=-97,30;-97.01,30.01&steps=1';
// Three rapid reroutes of the same A→B, before any of them answers.
const all = [
request(route, query),
request(route, query),
request(route, query),
];
// ...plus a caller that wants no steps; it rides the same call.
all.push(request(route, '?profile=car&coords=-97,30;-97.01,30.01'));
await Promise.resolve();
assert.equal(
_routeInflightCountForTest(),
1,
'one upstream call is in flight',
);
release();
const results = await Promise.all(all);
assert.equal(calls, 1, 'the FOSSGIS servers saw one request, not four');
assert.equal(results[0].body.steps.length, 4);
assert.deepEqual(results[0].body, results[1].body);
assert.deepEqual(results[0].body, results[2].body);
assert.equal(
'steps' in results[3].body,
false,
'the stepless caller still gets no steps',
);
assert.equal(
_routeInflightCountForTest(),
0,
'the in-flight entry is released',
);
});
test('a pathological maneuver count is bounded before it reaches the browser', async (t) => {
_resetRouteUpstreamForTest();
const request = install(installRouteMiddleware);
t.mock.method(globalThis, 'fetch', async () =>
Response.json(osrmRouteWithSteps(5000)),
);
const result = await request(
'/api/route',
'?profile=car&coords=-97,30;-97.01,30.01&steps=1',
);
assert.equal(result.body.steps.length, ROUTE_STEPS_MAX);
assert.ok(ROUTE_STEPS_MAX <= 200, 'the cap stays a cap');
});
test('outbound route requests are spaced to the rate the routing service asks for', async (t) => {
_resetRouteUpstreamForTest();
const request = install(installRouteMiddleware);
const sentAt = [];
t.mock.method(globalThis, 'fetch', async (url) => {
sentAt.push({ at: Date.now(), url: String(url) });
return Response.json(osrmRouteWithSteps(2));
});
// FOSSGIS publish "one request per second max". The per-client limiter alone
// cannot honour that: a DRIVE and a WALK of the same trip are two different
// cache keys and used to leave 48 ms apart.
assert.ok(ROUTE_UPSTREAM_MIN_INTERVAL_MS >= 1000);
const first = await request(
'/api/route',
'?profile=car&coords=-97,30;-97.01,30.01',
);
assert.equal(first.body.ok, true);
assert.equal(sentAt.length, 1);
const second = request(
'/api/route',
'?profile=foot&coords=-97,30;-97.01,30.01',
);
await new Promise((resolve) => setTimeout(resolve, 120));
assert.equal(sentAt.length, 1, 'the second profile waits for its slot');
assert.equal((await second).body.ok, true);
assert.ok(
sentAt[1].at - sentAt[0].at >= ROUTE_UPSTREAM_MIN_INTERVAL_MS - 20,
`two outbound calls ${sentAt[1].at - sentAt[0].at} ms apart`,
);
// A cached route costs no outbound slot at all.
const cached = await request(
'/api/route',
'?profile=car&coords=-97,30;-97.01,30.01',
);
assert.equal(cached.body.ok, true);
assert.equal(sentAt.length, 2, 'the cache answers without an upstream call');
});
test('past the outbound queue depth the answer is an honest 429, not a growing queue', async (t) => {
_resetRouteUpstreamForTest();
// The real gate is one per second (asserted above); this test is about what
// happens past the queue depth, so it runs the gate fast.
_setRouteUpstreamIntervalForTest(5);
t.after(() => _resetRouteUpstreamForTest());
const request = install(installRouteMiddleware);
t.mock.method(globalThis, 'fetch', async () =>
Response.json(osrmRouteWithSteps(2)),
);
const pending = [];
for (let i = 0; i < ROUTE_UPSTREAM_QUEUE_MAX + 3; i += 1) {
pending.push(
request(
'/api/route',
`?profile=car&coords=-97,${30 + i / 100};-97.01,30.01`,
),
);
}
const results = await Promise.all(pending);
const refused = results.filter((result) => result.statusCode === 429);
assert.ok(refused.length >= 1, 'the excess is refused rather than queued');
assert.match(refused[0].body.error, /routing busy/);
assert.ok(
Number(refused[0].headers['retry-after']) >= 1,
`Retry-After is ${refused[0].headers['retry-after']}`,
);
// Everything that was admitted still got a real answer.
assert.ok(results.some((result) => result.body.ok === true));
});
test('an upstream rate limit is reported as one, not as "no route found"', async (t) => {
_resetRouteUpstreamForTest();
const request = install(installRouteMiddleware);
t.mock.method(
globalThis,
'fetch',
async () =>
new Response('slow down', {
status: 429,
headers: { 'retry-after': '30', 'content-type': 'text/plain' },
}),
);
const result = await request(
'/api/route',
'?profile=car&coords=-97,30;-97.01,30.01',
);
assert.equal(result.statusCode, 429);
assert.match(result.body.error, /routing service is rate limited/);
assert.equal(
result.headers['retry-after'],
'30',
"the service's own wait is passed on",
);
});
test('the upstream host is pinned: a redirect is refused, not followed', async (t) => {
_resetRouteUpstreamForTest();
const request = install(installRouteMiddleware);
const seen = [];
t.mock.method(globalThis, 'fetch', async (url, options) => {
seen.push(options?.redirect);
return Response.json(osrmRouteWithSteps(2));
});
await request('/api/route', '?profile=car&coords=-97,30;-97.01,30.01');
assert.deepEqual(seen, ['error'], 'a redirect would escape the pinned host');
});
test('both paid Google routes refuse cross-site requests before calling Google', async (t) => {
const plugin = googlePlacesContextProxy({ resolveApiKey: () => 'key' });
const routes = new Map();
plugin.configureServer({
middlewares: { use: (route, handler) => routes.set(route, handler) },
});
let upstream = 0;
t.mock.method(globalThis, 'fetch', async () => {
upstream += 1;
return Response.json({ places: [] });
});
const call = async (route, url, headers) => {
const res = {
statusCode: 200,
headers: {},
setHeader(name, value) {
this.headers[name.toLowerCase()] = value;
},
writeHead(status) {
this.statusCode = status;
},
end() {},
};
await routes.get(route)(
{
url,
method: 'GET',
headers: { host: 'localhost:4173', ...headers },
socket: { remoteAddress: '127.0.0.1' },
},
res,
);
return res.statusCode;
};
const crossSite = {
origin: 'https://attacker.example',
'sec-fetch-site': 'cross-site',
};
for (const [route, url] of [
['/api/google/nearby-places', '?lat=30&lon=-97'],
['/api/google/text-search', '?q=museum&lat=30&lon=-97'],
]) {
assert.equal(await call(route, url, crossSite), 403, route);
assert.equal(
await call(route, url, { 'sec-fetch-site': 'cross-site' }),
403,
route,
);
}
assert.equal(upstream, 0, 'a refused request never reaches Google');
// The app's own page still gets answers.
assert.equal(
await call('/api/google/text-search', '?q=museum&lat=30&lon=-97', {
origin: 'http://localhost:4173',
'sec-fetch-site': 'same-origin',
}),
200,
);
assert.equal(upstream, 1);
});