// TERRAIN HEIGHTS — the bounds on a cache keyed by caller-chosen coordinates. // // Every distinct 5dp lon/lat is its own cache key, its own permanent line in // `.gev-cache/terrain-heights.json`, and its own upstream call. Four things // bound that: WGS-84 range at parse, an entry ceiling on the map, a size // ceiling on the file that is read back, and a per-client request limiter. // // These cases drive the real exported route handler. `fetch` is stubbed per // test and restored after, so a case that reaches upstream fails loudly. // // Run with: npm test import { test } from 'node:test'; import assert from 'node:assert/strict'; import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { parseTerrainPoints } from './data/terrainHeightsProxy.js'; import { terrainHeightsProxy } from '../server/providers/terrain.js'; import { admitSameSite } from '../server/providers/common/same-site.js'; function install(plugin) { const routes = new Map(); plugin.configureServer({ middlewares: { use(path, handler) { routes.set(path, handler); }, }, }); return routes; } function request( handler, { url = '/', remoteAddress = '127.0.0.1', reqHeaders = {} } = {}, ) { return new Promise((resolve, reject) => { const headers = new Map(); const req = { method: 'GET', url, headers: reqHeaders, socket: { remoteAddress }, }; const res = { statusCode: 200, headersSent: false, setHeader(name, value) { headers.set(String(name).toLowerCase(), String(value)); }, writeHead(status, hdrs = {}) { this.statusCode = status; this.headersSent = true; for (const [name, value] of Object.entries(hdrs)) headers.set(String(name).toLowerCase(), String(value)); }, end(body = '') { let parsed = null; try { parsed = body ? JSON.parse(String(body)) : null; } catch { parsed = String(body); } resolve({ statusCode: this.statusCode, headers: Object.fromEntries(headers), body: parsed, }); }, }; Promise.resolve(handler(req, res)).catch(reject); }); } function stubFetch(t, impl) { const original = globalThis.fetch; globalThis.fetch = impl; t.after(() => { globalThis.fetch = original; }); } /** * Answer like Re:Earth does: one `{ellipsoid}` per requested point, in order. * Records which point strings were actually asked for, so a test can assert on * cache misses rather than on internal state. */ function stubTerrainUpstream(t) { const asked = []; stubFetch(t, async (url) => { const points = decodeURIComponent( new URL(String(url)).searchParams.get('points') || '', ) .split(';') .filter(Boolean); asked.push(...points); return Response.json({ results: points.map(() => ({ ellipsoid: 150 })) }); }); return asked; } /** * A cache file of the test's own, removed when the test ends. Without one the * proxy falls back to this checkout's `.gev-cache/terrain-heights.json`, and a * point the app has already cached there would answer a request the test * expects to reach the stubbed upstream (#1242). */ async function isolatedCachePath(t) { const dir = await mkdtemp(path.join(tmpdir(), 'gev-terrain-')); t.after(() => rm(dir, { recursive: true, force: true })); return path.join(dir, 'terrain-heights.json'); } test('a coordinate off the globe is not a cache key', () => { // Finite but not on Earth — the old guard accepted every one of these. for (const raw of [ '0,91', '0,-91', '181,0', '-181,0', '1e9,1e9', '0,90.00001', ]) assert.equal(parseTerrainPoints(raw), null, raw); // The poles and the antimeridian are real places and must still parse. for (const raw of ['0,90', '0,-90', '180,0', '-180,0', '-97.74,30.26']) assert.ok(parseTerrainPoints(raw), raw); }); test('an out-of-range point is refused before it reaches upstream or the cache', async (t) => { let upstreamCalls = 0; stubFetch(t, async () => { upstreamCalls += 1; return new Response('[]', { status: 200 }); }); const route = install( terrainHeightsProxy({ cachePath: await isolatedCachePath(t) }), ).get('/api/terrain/heights'); const refused = await request(route, { url: '/?points=0,91' }); assert.equal(refused.statusCode, 400); assert.equal( upstreamCalls, 0, 'an impossible coordinate must not cost an upstream call', ); }); test('one out-of-range point rejects the batch it is hidden in', async (t) => { stubFetch(t, async () => new Response('[]', { status: 200 })); const route = install( terrainHeightsProxy({ cachePath: await isolatedCachePath(t) }), ).get('/api/terrain/heights'); const mixed = await request(route, { url: '/?points=-97.74,30.26;0,999;-97.75,30.27', }); assert.equal(mixed.statusCode, 400); }); test('the point cache stops growing, and an evicted point is simply refetched', async (t) => { const asked = stubTerrainUpstream(t); // A ceiling of 3 exercises the same code path 50,000 does, in four requests. const route = install( terrainHeightsProxy({ cachePath: await isolatedCachePath(t), cacheMaxEntries: 3, }), ).get('/api/terrain/heights'); const point = (i) => `-97.7${i}000,30.26000`; for (let i = 1; i <= 4; i += 1) await request(route, { url: `/?points=${point(i)}` }); assert.equal(asked.length, 4, 'four distinct points, four upstream lookups'); // The newest point is still cached... await request(route, { url: `/?points=${point(4)}` }); assert.equal(asked.length, 4, 'a cached point costs nothing'); // ...and the oldest was evicted, so it costs one refetch — never a wrong // answer, because terrain does not move. await request(route, { url: `/?points=${point(1)}` }); assert.equal(asked.length, 5, 'the evicted point was asked for again'); assert.equal(asked[4], point(1)); }); test('a refetched point counts as new when the cache evicts', async (t) => { const root = await mkdtemp(path.join(tmpdir(), 'terrain-evict-order-')); t.after(() => rm(root, { recursive: true, force: true })); const cachePath = path.join(root, 'cache.json'); const day = 24 * 3600_000; const now = Date.now(); const point = (i) => `-97.7${i}000,30.26000`; const entry = (at) => ({ at, result: { ellipsoid: 150 } }); // Written newest first: the file's order is not the age order. Point 1 is // past the 30-day TTL, so the next request for it refetches. await writeFile( cachePath, JSON.stringify({ version: 2, points: { [point(3)]: entry(now - 3600_000), [point(2)]: entry(now - day), [point(1)]: entry(now - 31 * day), }, }), ); const asked = stubTerrainUpstream(t); const route = install( terrainHeightsProxy({ cacheMaxEntries: 3, cachePath }), ).get('/api/terrain/heights'); await request(route, { url: `/?points=${point(1)}` }); assert.deepEqual(asked, [point(1)], 'the stale point is refetched'); await request(route, { url: `/?points=${point(4)}` }); assert.equal(asked.length, 2); // Point 2 is now the oldest fetch; point 1 was just refreshed. await request(route, { url: `/?points=${point(1)};${point(3)}` }); assert.equal(asked.length, 2, 'the refreshed and the recent point stay'); await request(route, { url: `/?points=${point(2)}` }); assert.deepEqual(asked.slice(2), [point(2)], 'the oldest fetch was evicted'); }); test('an over-large cache file is not read back into memory', async (t) => { const asked = stubTerrainUpstream(t); const point = '-97.74000,30.26000'; const cachePath = await isolatedCachePath(t); // A cache file holding the answer, but larger than the ceiling allows. const padded = { version: 2, points: { [point]: { at: Date.now(), result: { ellipsoid: 150 } } }, _pad: 'x'.repeat(4096), }; await writeFile(cachePath, JSON.stringify(padded), 'utf8'); const route = install( terrainHeightsProxy({ cachePath, cacheMaxDiskBytes: 1024 }), ).get('/api/terrain/heights'); await request(route, { url: `/?points=${point}` }); assert.deepEqual( asked, [point], 'the oversized file was skipped, so the point was a miss', ); // The same file under a ceiling that admits it IS used, which is what proves // the previous assertion came from the size guard and not a broken path. const admitted = install( terrainHeightsProxy({ cachePath, cacheMaxDiskBytes: 1024 * 1024 }), ).get('/api/terrain/heights'); await request(admitted, { url: `/?points=${point}` }); assert.deepEqual(asked, [point], 'a cache within the ceiling is still read'); }); test('the route refuses a flood of requests from one client', async (t) => { stubTerrainUpstream(t); const route = install( terrainHeightsProxy({ cachePath: await isolatedCachePath(t) }), ).get('/api/terrain/heights'); let refusedAt = 0; for (let i = 1; i <= 95; i += 1) { // A different point each time: distinct keys are the growth path. const lon = (-97.7 - i / 1000).toFixed(5); const answer = await request(route, { url: `/?points=${lon},30.26` }); if (answer.statusCode === 429) { refusedAt = i; break; } } assert.equal(refusedAt, 91, 'the 91st request in a minute is refused'); }); test('a cross-site page cannot fill the cache or spend the visitor quota', async (t) => { const asked = stubTerrainUpstream(t); const route = install( terrainHeightsProxy({ cachePath: await isolatedCachePath(t) }), ).get('/api/terrain/heights'); const host = 'localhost:5173'; const refused = [ { host, 'sec-fetch-site': 'cross-site' }, // from any web page { host, 'sec-fetch-site': 'same-site' }, { host, origin: 'https://evil.example' }, // fetch from another page { host, origin: 'null' }, // sandboxed frame // Behind a reverse proxy the browser's own Sec-Fetch-Site still decides. { host, 'x-forwarded-for': '203.0.113.9', 'sec-fetch-site': 'cross-site' }, ]; // More refused requests than the 90/min quota, each a distinct new key. for (let i = 0; i < 100; i += 1) { const reqHeaders = refused[i % refused.length]; const answer = await request(route, { url: `/?points=${(-97 - i / 1000).toFixed(5)},30.26`, reqHeaders, }); assert.equal(answer.statusCode, 403, JSON.stringify(reqHeaders)); } assert.deepEqual(asked, [], 'nothing reached upstream or the cache'); // The visitor's own app still has its full quota afterwards, and a // loopback tool that sends no browser headers is admitted too. const app = await request(route, { url: '/?points=-97.74,30.26', reqHeaders: { host, 'sec-fetch-site': 'same-origin' }, }); assert.equal(app.statusCode, 200); const tool = await request(route, { url: '/?points=-97.75,30.26' }); assert.equal(tool.statusCode, 200); assert.equal(asked.length, 2); }); test('the app served behind a reverse proxy still gets terrain', async (t) => { const asked = stubTerrainUpstream(t); const route = install( terrainHeightsProxy({ cachePath: await isolatedCachePath(t) }), ).get('/api/terrain/heights'); const host = 'globe.example.lan'; // What nginx/Caddy/Cloudflare add in front of a same-origin app fetch. The // cost-bearing routes refuse these; terrain is keyless and needed to render, // so a 403 here would drop every chunk to the geoid fallback. const proxied = [ { host, 'sec-fetch-site': 'same-origin', 'x-forwarded-for': '203.0.113.9', }, { host, 'sec-fetch-site': 'same-origin', 'cf-connecting-ip': '203.0.113.9', }, { host, 'sec-fetch-site': 'same-origin', forwarded: 'for=203.0.113.9;proto=https', via: '1.1 caddy', }, // TLS terminated at the proxy: the page origin is https, the hop is http. { host, origin: `https://${host}`, 'sec-fetch-site': 'same-origin', 'x-forwarded-proto': 'https', 'x-forwarded-for': '203.0.113.9', }, ]; for (const [i, reqHeaders] of proxied.entries()) { const answer = await request(route, { url: `/?points=${(-97.8 - i / 1000).toFixed(5)},30.26`, reqHeaders, }); assert.equal(answer.statusCode, 200, JSON.stringify(reqHeaders)); } assert.equal(asked.length, proxied.length); // Proxied or not, a cross-site page is still refused before any work. for (const reqHeaders of [ { host, 'x-forwarded-for': '203.0.113.9', 'sec-fetch-site': 'cross-site' }, { host, 'x-forwarded-proto': 'https', origin: 'https://evil.example', }, // A forwarded scheme does not make an http Origin match an https page. { host, 'x-forwarded-proto': 'https', origin: `http://${host}` }, ]) { const answer = await request(route, { url: '/?points=-97.9,30.26', reqHeaders, }); assert.equal(answer.statusCode, 403, JSON.stringify(reqHeaders)); } assert.equal(asked.length, proxied.length, 'refusals cost no upstream call'); }); test('cost-bearing routes still refuse proxied requests', () => { // The keyless exemption is scoped to terrain; the shared gate is unchanged. const res = { statusCode: 200, headers: {}, setHeader(name, value) { this.headers[name.toLowerCase()] = value; }, end(body) { this.body = body; }, }; const refused = admitSameSite( { headers: { host: 'globe.example.lan', 'sec-fetch-site': 'same-origin', 'x-forwarded-for': '203.0.113.9', }, socket: {}, }, res, ); assert.equal(refused, true); assert.equal(res.statusCode, 403); assert.deepEqual(JSON.parse(res.body), { error: 'Proxied requests are not accepted', }); }); test('a second client keeps its own quota', async (t) => { stubFetch(t, async () => Response.json([{ lng: -97.74, lat: 30.26, height: 150 }]), ); const route = install( terrainHeightsProxy({ cachePath: await isolatedCachePath(t) }), ).get('/api/terrain/heights'); for (let i = 0; i < 90; i += 1) await request(route, { url: `/?points=${(-98 - i / 1000).toFixed(5)},30.26`, remoteAddress: '198.51.100.7', }); const exhausted = await request(route, { url: '/?points=-99.5,30.26', remoteAddress: '198.51.100.7', }); assert.equal(exhausted.statusCode, 429); const neighbour = await request(route, { url: '/?points=-99.6,30.26', remoteAddress: '198.51.100.8', }); assert.notEqual(neighbour.statusCode, 429); }); test('one 64-point chunk is accepted; larger batches fail before cache or upstream work', async (t) => { const root = await mkdtemp(path.join(tmpdir(), 'terrain-batch-limit-')); t.after(() => rm(root, { recursive: true, force: true })); const asked = stubTerrainUpstream(t); const route = install( terrainHeightsProxy({ cachePath: path.join(root, 'cache.json') }), ).get('/api/terrain/heights'); const points = (count) => Array.from({ length: count }, (_, i) => `${i / 1000},30`).join(';'); for (const count of [65, 2001]) { const response = await request(route, { url: `/?points=${points(count)}` }); assert.equal(response.statusCode, 413); assert.match(response.body.error, /max 64/); assert.equal(asked.length, 0); } const response = await request(route, { url: `/?points=${points(64)}` }); assert.equal(response.statusCode, 200); assert.equal(response.body.results.length, 64); assert.equal(asked.length, 64); });