mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
/api/osm/features keyed its rate limit and its one-search-per-client slot by the full peer address, so an IPv6 host could rotate addresses inside its /64 to take both global slots and slip the 12/min limit. It now uses clientNetworkKey, as /api/overpass does.
591 lines
17 KiB
JavaScript
591 lines
17 KiB
JavaScript
// THE BOUNDED OSM FEATURE SEARCH — `/api/osm/features`.
|
|
//
|
|
// A request names a curated preset, a box, a mode and a limit; the server
|
|
// writes the Overpass query and sends it only to an operator-configured
|
|
// Overpass (OVERPASS_UPSTREAMS). These cases pin what can never reach Overpass:
|
|
// unknown kinds, raw tags, oversized boxes, boundary-area scans, and a second
|
|
// upstream query while one is running. The generic `/api/overpass` guard is
|
|
// covered by its own tests and is not loosened here.
|
|
//
|
|
// Run with: npm test
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { EventEmitter } from 'node:events';
|
|
import {
|
|
compileOsmFeatureQuery,
|
|
OSM_FEATURE_LIMITS,
|
|
summarizeOsmFeatures,
|
|
} from '../../server/providers/overpass/osmFeatures.js';
|
|
import { installOsmFeaturesRoute } from '../../server/providers/overpass/osmFeaturesRoute.js';
|
|
import { sanitizeOverpassBody } from '../../server/providers/overpass/query.js';
|
|
import { OSM_QUERY_LIMITS } from '../voice/osmActions.js';
|
|
|
|
test('the server finishes its whole search before the voice answer gives up', () => {
|
|
// When the browser stops waiting, the route aborts the upstream query, so
|
|
// an OSM_TIMEOUT retry could never be served from the cache.
|
|
assert.ok(
|
|
OSM_FEATURE_LIMITS.timeoutS * 1000 < OSM_FEATURE_LIMITS.upstreamTimeoutMs,
|
|
'Overpass gives up before the server stops waiting for it',
|
|
);
|
|
assert.ok(
|
|
OSM_FEATURE_LIMITS.upstreamTimeoutMs <= OSM_FEATURE_LIMITS.upstreamBudgetMs,
|
|
);
|
|
assert.ok(
|
|
OSM_FEATURE_LIMITS.upstreamBudgetMs + 2_000 <= OSM_QUERY_LIMITS.budgetMs,
|
|
`server budget ${OSM_FEATURE_LIMITS.upstreamBudgetMs} ms must end before the client's ${OSM_QUERY_LIMITS.budgetMs} ms`,
|
|
);
|
|
});
|
|
|
|
test('a preset and a small box compile to a bounded query', () => {
|
|
const compiled = compileOsmFeatureQuery({
|
|
preset: 'hospital',
|
|
bbox: '85.2,27.6,85.4,27.8',
|
|
limit: '50',
|
|
});
|
|
assert.equal(compiled.ok, true);
|
|
assert.equal(
|
|
compiled.ql,
|
|
'[out:json][timeout:20][maxsize:67108864];(nwr["amenity"="hospital"](27.6,85.2,27.8,85.4););out center tags 50;',
|
|
);
|
|
assert.doesNotMatch(compiled.ql, /area/, 'no boundary-area scan');
|
|
const count = compileOsmFeatureQuery({
|
|
preset: 'bar',
|
|
bbox: [80, 20, 85, 25],
|
|
mode: 'count',
|
|
});
|
|
assert.match(
|
|
count.ql,
|
|
/nwr\["amenity"="bar"\].*nwr\["amenity"="pub"\].*out count;$/,
|
|
);
|
|
});
|
|
|
|
test('unknown kinds, raw tags, bad and oversized boxes are refused', () => {
|
|
assert.equal(
|
|
compileOsmFeatureQuery({ preset: 'amenity=hospital', bbox: '0,0,1,1' })
|
|
.code,
|
|
'UNKNOWN_PRESET',
|
|
);
|
|
assert.equal(
|
|
compileOsmFeatureQuery({ preset: '__proto__', bbox: '0,0,1,1' }).code,
|
|
'UNKNOWN_PRESET',
|
|
);
|
|
assert.equal(
|
|
compileOsmFeatureQuery({ preset: 'hospital', bbox: '0,0,1' }).code,
|
|
'BAD_BOX',
|
|
);
|
|
assert.equal(
|
|
compileOsmFeatureQuery({ preset: 'hospital', bbox: '0,1,1,0' }).code,
|
|
'BAD_BOX',
|
|
);
|
|
assert.equal(
|
|
compileOsmFeatureQuery({ preset: 'hospital', bbox: '0,0,3,3' }).code,
|
|
'AREA_TOO_LARGE',
|
|
);
|
|
assert.equal(
|
|
compileOsmFeatureQuery({
|
|
preset: 'hospital',
|
|
bbox: '0,0,3,3',
|
|
mode: 'count',
|
|
}).ok,
|
|
true,
|
|
);
|
|
assert.equal(
|
|
compileOsmFeatureQuery({
|
|
preset: 'hospital',
|
|
bbox: '0,0,7,7',
|
|
mode: 'count',
|
|
}).code,
|
|
'AREA_TOO_LARGE',
|
|
);
|
|
const limited = compileOsmFeatureQuery({
|
|
preset: 'hospital',
|
|
bbox: '0,0,1,1',
|
|
limit: 999999,
|
|
});
|
|
assert.match(
|
|
limited.ql,
|
|
new RegExp(`out center tags ${OSM_FEATURE_LIMITS.maxLimit};$`),
|
|
);
|
|
});
|
|
|
|
test('a box across the antimeridian is searched as its two halves', () => {
|
|
const compiled = compileOsmFeatureQuery({
|
|
preset: 'hospital',
|
|
bbox: '179.5,-17,-179.5,-16',
|
|
});
|
|
assert.equal(compiled.ok, true);
|
|
assert.match(compiled.ql, /\(-17,179\.5,-16,180\).*\(-17,-180,-16,-179\.5\)/);
|
|
});
|
|
|
|
test('the generic Overpass guard still refuses boundary-area element scans', () => {
|
|
const scan =
|
|
'data=' +
|
|
encodeURIComponent(
|
|
'[out:json];area(3600000001)->.a;nwr["amenity"="hospital"](area.a);out;',
|
|
);
|
|
assert.equal(sanitizeOverpassBody(scan).ok, false);
|
|
});
|
|
|
|
test('answers are compacted: centres, kept tags, truncation and counts', () => {
|
|
const compiled = compileOsmFeatureQuery({
|
|
preset: 'hospital',
|
|
bbox: '0,0,1,1',
|
|
limit: 2,
|
|
});
|
|
const features = summarizeOsmFeatures(
|
|
{
|
|
elements: [
|
|
{
|
|
type: 'node',
|
|
id: 1,
|
|
lat: 0.5,
|
|
lon: 0.5,
|
|
tags: { name: 'A', amenity: 'hospital', 'contact:phone': 'x' },
|
|
},
|
|
{
|
|
type: 'way',
|
|
id: 2,
|
|
center: { lat: 0.4, lon: 0.4 },
|
|
tags: { name: 'B', amenity: 'hospital' },
|
|
},
|
|
],
|
|
},
|
|
compiled,
|
|
);
|
|
assert.equal(features.count, 2);
|
|
assert.equal(features.truncated, true, 'the limit was reached');
|
|
assert.deepEqual(features.features[1], {
|
|
id: 'way/2',
|
|
lat: 0.4,
|
|
lon: 0.4,
|
|
tags: { name: 'B', amenity: 'hospital' },
|
|
});
|
|
assert.equal('contact:phone' in features.features[0].tags, false);
|
|
const count = summarizeOsmFeatures(
|
|
{
|
|
elements: [
|
|
{
|
|
type: 'count',
|
|
tags: { nodes: '3', ways: '2', relations: '0', total: '5' },
|
|
},
|
|
],
|
|
},
|
|
{ ...compiled, mode: 'count' },
|
|
);
|
|
assert.equal(count.count, 5);
|
|
});
|
|
|
|
function mount(
|
|
fetchPayload,
|
|
{
|
|
featuresCache,
|
|
configured = () => true,
|
|
upstreamTag,
|
|
upstreamBudgetMs,
|
|
} = {},
|
|
) {
|
|
const routes = new Map();
|
|
const writes = [];
|
|
const memoryCache = () => {
|
|
const held = new Map();
|
|
return {
|
|
get: async (key) => held.get(key),
|
|
set: async (key, value) => {
|
|
held.set(key, value);
|
|
writes.push(key);
|
|
},
|
|
};
|
|
};
|
|
installOsmFeaturesRoute(
|
|
{ use: (route, handler) => routes.set(route, handler) },
|
|
{
|
|
fetchPayload,
|
|
cacheRoot: null,
|
|
configured,
|
|
...(upstreamTag ? { upstreamTag } : {}),
|
|
...(upstreamBudgetMs ? { upstreamBudgetMs } : {}),
|
|
featuresCache: featuresCache || memoryCache(),
|
|
},
|
|
);
|
|
let client = 0;
|
|
const start = (
|
|
search,
|
|
route = '/api/osm/features',
|
|
headers = {},
|
|
from = null,
|
|
) => {
|
|
let req;
|
|
let res;
|
|
const promise = new Promise((resolve) => {
|
|
client += 1;
|
|
req = Object.assign(new EventEmitter(), {
|
|
method: 'GET',
|
|
url: `/?${search}`,
|
|
headers,
|
|
socket: { remoteAddress: from || `10.9.0.${client}` },
|
|
});
|
|
res = Object.assign(new EventEmitter(), {
|
|
writableEnded: false,
|
|
writeHead(status, headers) {
|
|
res.status = status;
|
|
res.headers = headers;
|
|
},
|
|
setHeader(name, value) {
|
|
res.headers = { ...(res.headers || {}), [name]: value };
|
|
},
|
|
end(body) {
|
|
res.writableEnded = true;
|
|
resolve({
|
|
status: res.status ?? res.statusCode,
|
|
headers: res.headers,
|
|
body: JSON.parse(body),
|
|
});
|
|
},
|
|
});
|
|
routes.get(route)(req, res);
|
|
});
|
|
return {
|
|
promise,
|
|
disconnect() {
|
|
res.emit('close');
|
|
},
|
|
};
|
|
};
|
|
const request = (search, route = '/api/osm/features', headers, from) =>
|
|
start(search, route, headers, from).promise;
|
|
return { request, start, writes };
|
|
}
|
|
|
|
test('a search without a limit lists up to 500 features', async () => {
|
|
const sent = [];
|
|
const { request } = mount(async (body) => {
|
|
sent.push(decodeURIComponent(body));
|
|
return { status: 200, body: JSON.stringify({ elements: [] }) };
|
|
});
|
|
for (const search of [
|
|
'preset=hospital&bbox=0,0,1,1',
|
|
'preset=school&bbox=0,0,1,1&limit=',
|
|
]) {
|
|
const answer = await request(search);
|
|
assert.equal(answer.status, 200, search);
|
|
}
|
|
assert.equal(sent.length, 2);
|
|
for (const ql of sent) assert.match(ql, /out center tags 500;$/);
|
|
assert.equal(
|
|
compileOsmFeatureQuery({ preset: 'hospital', bbox: '0,0,1,1', limit: '7' })
|
|
.limit,
|
|
7,
|
|
);
|
|
});
|
|
|
|
test('one IPv6 /64 is one client for slots and the rate limit', async () => {
|
|
const release = [];
|
|
const { request } = mount(async () => {
|
|
await new Promise((r) => release.push(r));
|
|
return { status: 200, body: JSON.stringify({ elements: [] }) };
|
|
});
|
|
const first = request(
|
|
'preset=hospital&bbox=0,0,1,1',
|
|
undefined,
|
|
{},
|
|
'2001:db8:1:2::a',
|
|
);
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
// A rotated address that wrongly got its own slot would wait upstream
|
|
// too: answer every held search before asserting, so a failure never hangs.
|
|
const rotated = request(
|
|
'preset=school&bbox=0,0,1,1',
|
|
undefined,
|
|
{},
|
|
'2001:db8:1:2:ffff::b',
|
|
);
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
release.splice(0).forEach((r) => r());
|
|
assert.equal(
|
|
(await rotated).status,
|
|
503,
|
|
'a rotated address waits for its first search',
|
|
);
|
|
assert.equal((await first).status, 200);
|
|
|
|
// Twelve a minute per /64, wherever in it the requests come from.
|
|
const { request: limited } = mount(async () => ({
|
|
status: 200,
|
|
body: JSON.stringify({ elements: [] }),
|
|
}));
|
|
const statuses = [];
|
|
for (let i = 0; i < 13; i += 1)
|
|
statuses.push(
|
|
(
|
|
await limited(
|
|
`preset=hospital&bbox=0,0,1,${(i + 1) / 20}`,
|
|
undefined,
|
|
{},
|
|
`2001:db8:5:6::${(i + 1).toString(16)}`,
|
|
)
|
|
).status,
|
|
);
|
|
assert.deepEqual(statuses.slice(0, 12), Array(12).fill(200));
|
|
assert.equal(statuses[12], 429);
|
|
});
|
|
|
|
test('the route answers, caches, and shares upstream slots fairly', async () => {
|
|
const release = [];
|
|
let upstream = 0;
|
|
const { request, writes } = mount(async (body) => {
|
|
upstream += 1;
|
|
assert.match(decodeURIComponent(body), /^data=\[out:json\]/);
|
|
await new Promise((r) => release.push(r));
|
|
return {
|
|
status: 200,
|
|
body: JSON.stringify({
|
|
elements: [{ type: 'node', id: 7, lat: 1, lon: 1, tags: {} }],
|
|
}),
|
|
};
|
|
});
|
|
const tick = () => new Promise((r) => setTimeout(r, 5));
|
|
const first = request(
|
|
'preset=hospital&bbox=0,0,2,2',
|
|
undefined,
|
|
{},
|
|
'10.1.0.1',
|
|
);
|
|
await tick();
|
|
const sameClient = await request(
|
|
'preset=school&bbox=0,0,2,2',
|
|
undefined,
|
|
{},
|
|
'10.1.0.1',
|
|
);
|
|
assert.equal(
|
|
sameClient.status,
|
|
503,
|
|
"one client's second search waits for its first",
|
|
);
|
|
const shared = request(
|
|
'preset=hospital&bbox=0,0,2,2',
|
|
undefined,
|
|
{},
|
|
'10.1.0.2',
|
|
);
|
|
const second = request(
|
|
'preset=school&bbox=0,0,2,2',
|
|
undefined,
|
|
{},
|
|
'10.1.0.3',
|
|
);
|
|
await tick();
|
|
assert.equal(
|
|
upstream,
|
|
2,
|
|
'another client runs alongside; the same search is shared',
|
|
);
|
|
const third = await request(
|
|
'preset=pharmacy&bbox=0,0,2,2',
|
|
undefined,
|
|
{},
|
|
'10.1.0.4',
|
|
);
|
|
assert.equal(third.status, 503, 'the global cap holds');
|
|
release.forEach((r) => r());
|
|
const answered = await first;
|
|
assert.equal(answered.status, 200);
|
|
assert.equal(answered.body.count, 1);
|
|
assert.equal((await shared).status, 200);
|
|
assert.equal((await second).status, 200);
|
|
const again = await request('preset=hospital&bbox=0,0,2,2');
|
|
assert.equal(again.headers['X-OSM-Cache'], 'HIT');
|
|
assert.equal(upstream, 2);
|
|
assert.equal(writes.length, 2);
|
|
const refused = await request('preset=hospital&bbox=0,0,9,9');
|
|
assert.equal(refused.status, 400);
|
|
assert.equal(refused.body.code, 'AREA_TOO_LARGE');
|
|
});
|
|
|
|
test('a hung Overpass is cut off by the overall budget, across every endpoint', async () => {
|
|
const { request } = mount(
|
|
(_body, _max, { signal }) =>
|
|
new Promise((_, reject) =>
|
|
signal.addEventListener('abort', () => reject(signal.reason), {
|
|
once: true,
|
|
}),
|
|
),
|
|
{ upstreamBudgetMs: 30 },
|
|
);
|
|
const started = Date.now();
|
|
const answer = await request('preset=hospital&bbox=0,0,2,2');
|
|
assert.ok(Date.now() - started < 1000);
|
|
assert.equal(answer.status, 504);
|
|
});
|
|
|
|
test('a cached answer is served only by the Overpass instance that gave it', async () => {
|
|
let tag = 'instance-a';
|
|
let upstream = 0;
|
|
const { request } = mount(
|
|
async () => {
|
|
upstream += 1;
|
|
return {
|
|
status: 200,
|
|
body: JSON.stringify({
|
|
elements: [{ type: 'node', id: 7, lat: 1, lon: 1, tags: {} }],
|
|
}),
|
|
};
|
|
},
|
|
{ upstreamTag: () => tag },
|
|
);
|
|
assert.equal((await request('preset=hospital&bbox=0,0,2,2')).status, 200);
|
|
const hit = await request('preset=hospital&bbox=0,0,2,2');
|
|
assert.equal(hit.headers['X-OSM-Cache'], 'HIT');
|
|
assert.equal(upstream, 1);
|
|
tag = 'instance-b';
|
|
const switched = await request('preset=hospital&bbox=0,0,2,2');
|
|
assert.equal(switched.headers['X-OSM-Cache'], 'MISS');
|
|
assert.equal(
|
|
upstream,
|
|
2,
|
|
'the new instance is asked, not the old answer served',
|
|
);
|
|
});
|
|
|
|
test('a cross-site page cannot spend the operator Overpass', async () => {
|
|
let upstream = 0;
|
|
const { request } = mount(async () => {
|
|
upstream += 1;
|
|
return { status: 200, body: JSON.stringify({ elements: [] }) };
|
|
});
|
|
for (const headers of [
|
|
{ 'sec-fetch-site': 'cross-site', origin: 'https://evil.example' },
|
|
{ 'sec-fetch-site': 'cross-site' },
|
|
{ origin: 'https://evil.example' },
|
|
]) {
|
|
const refused = await request('preset=hospital&bbox=0,0,2,2', undefined, {
|
|
host: 'localhost:5173',
|
|
...headers,
|
|
});
|
|
assert.equal(refused.status, 403, JSON.stringify(headers));
|
|
}
|
|
assert.equal(upstream, 0, 'no cross-site request reaches Overpass');
|
|
const own = await request('preset=hospital&bbox=0,0,2,2', undefined, {
|
|
host: 'localhost:5173',
|
|
'sec-fetch-site': 'same-origin',
|
|
});
|
|
assert.equal(own.status, 200);
|
|
// Like /api/overpass, it works behind a reverse proxy: forwarding headers
|
|
// alone are not a refusal.
|
|
const proxied = await request('preset=school&bbox=0,0,2,2', undefined, {
|
|
host: 'localhost:5173',
|
|
'sec-fetch-site': 'same-origin',
|
|
'x-forwarded-for': '203.0.113.7',
|
|
});
|
|
assert.equal(proxied.status, 200);
|
|
});
|
|
|
|
test('an upstream refusal is not an empty answer', async () => {
|
|
const { request } = mount(async () => ({
|
|
status: 429,
|
|
body: 'rate_limited',
|
|
rateLimited: true,
|
|
}));
|
|
const answer = await request('preset=hospital&bbox=0,0,1,1');
|
|
assert.equal(answer.status, 429);
|
|
assert.ok(answer.body.error);
|
|
});
|
|
|
|
test('malformed successful answers are refused and never cached', async () => {
|
|
const payloads = [
|
|
{},
|
|
{ elements: [{ type: 'count', tags: { nodes: '2' } }] },
|
|
{ elements: [{ type: 'way', id: 9, tags: { amenity: 'hospital' } }] },
|
|
];
|
|
const { request, writes } = mount(async () => ({
|
|
status: 200,
|
|
body: JSON.stringify(payloads.shift()),
|
|
}));
|
|
const missingElements = await request('preset=hospital&bbox=0,0,1,1');
|
|
const missingTotal = await request('preset=hospital&bbox=0,0,1,1&mode=count');
|
|
const unusableRows = await request('preset=hospital&bbox=1,1,2,2');
|
|
for (const answer of [missingElements, missingTotal, unusableRows]) {
|
|
assert.equal(answer.status, 502);
|
|
assert.match(answer.body.error, /failed/i);
|
|
}
|
|
assert.deepEqual(writes, []);
|
|
});
|
|
|
|
test('the last disconnected waiter aborts upstream work and frees the slot', async () => {
|
|
let calls = 0;
|
|
let firstStarted;
|
|
let firstAborted;
|
|
const started = new Promise((resolve) => {
|
|
firstStarted = resolve;
|
|
});
|
|
const aborted = new Promise((resolve) => {
|
|
firstAborted = resolve;
|
|
});
|
|
const { request, start } = mount(async (_body, _cap, { signal }) => {
|
|
calls += 1;
|
|
if (calls === 1) {
|
|
firstStarted();
|
|
return new Promise((resolve, reject) => {
|
|
signal.addEventListener(
|
|
'abort',
|
|
() => {
|
|
firstAborted();
|
|
reject(signal.reason);
|
|
},
|
|
{ once: true },
|
|
);
|
|
});
|
|
}
|
|
return { status: 200, body: '{"elements":[]}' };
|
|
});
|
|
const abandoned = start('preset=hospital&bbox=0,0,1,1');
|
|
await started;
|
|
abandoned.disconnect();
|
|
await aborted;
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
const next = await request('preset=school&bbox=0,0,1,1');
|
|
assert.equal(next.status, 200);
|
|
assert.equal(calls, 2);
|
|
});
|
|
|
|
test('one disconnected coalesced waiter does not cancel a live sibling', async () => {
|
|
let release;
|
|
let upstreamSignal;
|
|
let calls = 0;
|
|
const { start } = mount(async (_body, _cap, { signal }) => {
|
|
calls += 1;
|
|
upstreamSignal = signal;
|
|
await new Promise((resolve) => {
|
|
release = resolve;
|
|
});
|
|
return { status: 200, body: '{"elements":[]}' };
|
|
});
|
|
const first = start('preset=hospital&bbox=0,0,1,1');
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
const second = start('preset=hospital&bbox=0,0,1,1');
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
first.disconnect();
|
|
assert.equal(upstreamSignal.aborted, false);
|
|
release();
|
|
const answer = await second.promise;
|
|
assert.equal(answer.status, 200);
|
|
assert.equal(calls, 1);
|
|
});
|
|
|
|
test('without a configured Overpass the route refuses and fetches nothing', async () => {
|
|
let upstream = 0;
|
|
const { request } = mount(
|
|
async () => {
|
|
upstream += 1;
|
|
return { status: 200, body: '{"elements":[]}' };
|
|
},
|
|
{ configured: () => false },
|
|
);
|
|
const answer = await request('preset=hospital&bbox=85.2,27.6,85.4,27.8');
|
|
assert.equal(answer.status, 503);
|
|
assert.equal(answer.body.code, 'OVERPASS_NOT_CONFIGURED');
|
|
assert.equal(answer.body.retryable, false);
|
|
assert.match(answer.body.error, /configured Overpass/);
|
|
assert.equal(upstream, 0);
|
|
});
|