mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
390 lines
13 KiB
JavaScript
390 lines
13 KiB
JavaScript
// 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;
|
|
}
|
|
|
|
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()).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()).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({ 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('an over-large cache file is not read back into memory', async (t) => {
|
|
const asked = stubTerrainUpstream(t);
|
|
const point = '-97.74000,30.26000';
|
|
const dir = await mkdtemp(path.join(tmpdir(), 'gev-terrain-'));
|
|
const cachePath = path.join(dir, 'terrain-heights.json');
|
|
t.after(() => rm(dir, { recursive: true, force: true }));
|
|
|
|
// 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()).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()).get('/api/terrain/heights');
|
|
const host = 'localhost:5173';
|
|
const refused = [
|
|
{ host, 'sec-fetch-site': 'cross-site' }, // <img src> 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()).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()).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);
|
|
});
|