// 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); });