From 691be39c1b466666b5a2f757b12c1d8c8bcb5226 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 11 Oct 2026 09:38:06 -0400 Subject: [PATCH 1/7] fix(tools): stop cancelled calls before execution and discard late results --- CHANGELOG.md | 2 ++ docs/CURRENT-STATE.md | 5 +++ src/tools/catalog-cancellation.test.mjs | 41 +++++++++++++++++++++++++ src/tools/catalog.js | 2 ++ 4 files changed, 50 insertions(+) create mode 100644 src/tools/catalog-cancellation.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7acdedf6b..33ebae897 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ the five borough cores load and `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is unchanged; extra cameras share it round-robin with the other packs. +- Cancelled tool calls refuse actions before execution and discard results that settle after cancellation. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c77d1d8e3..47ce15815 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5310,3 +5310,8 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + + +### fix(tools): stop cancelled calls before execution and discard late results + +Check cancellation before invoking the tool and after it resolves, including nested calls that share the same signal. This does not promise rollback of an action already completed. diff --git a/src/tools/catalog-cancellation.test.mjs b/src/tools/catalog-cancellation.test.mjs new file mode 100644 index 000000000..a374bd71e --- /dev/null +++ b/src/tools/catalog-cancellation.test.mjs @@ -0,0 +1,41 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { composeCatalog, defineTool } from './catalog.js'; +test('cancelled catalog calls cannot execute or return late results', async () => { + let calls = 0, + release, + block = false; + const action = defineTool({ + name: 'fixture_action', + kind: 'action', + title: 'Fixture', + description: 'Offline fixture', + inputSchema: { type: 'object' }, + run: async () => { + calls++; + return block + ? new Promise((r) => (release = r)) + : { summary: 'fixture', data: {} }; + }, + }); + const catalog = composeCatalog({ tools: [action] }); + const c = new AbortController(); + c.abort(); + await assert.rejects( + catalog.call('fixture_action', {}, { signal: c.signal }), + { name: 'AbortError' }, + ); + assert.equal(calls, 0); + block = true; + const running = new AbortController(); + const pending = catalog.call( + 'fixture_action', + {}, + { signal: running.signal }, + ); + while (!release) await Promise.resolve(); + running.abort(); + release({ summary: 'late', data: {} }); + await assert.rejects(pending, { name: 'AbortError' }); + assert.equal(calls, 1); +}); diff --git a/src/tools/catalog.js b/src/tools/catalog.js index 4d5106b6e..6faf82ce5 100644 --- a/src/tools/catalog.js +++ b/src/tools/catalog.js @@ -178,6 +178,7 @@ export function composeCatalog({ return chain({ tool, args, signal, ...(parent ? { parent } : {}) }); }; const execute = async ({ tool, args, signal }) => { + signal?.throwIfAborted(); const problems = validateValue(tool.inputSchema, args); if (problems.length) throw new ToolError('invalid_arguments', problems.join('; ')); @@ -194,6 +195,7 @@ export function composeCatalog({ } catch (error) { throw fromSourceError(error); } + signal?.throwIfAborted(); if (!result || typeof result.summary !== 'string' || !result.data) throw new TypeError(`${tool.name} returned no summary or data`); if ( From 0796efe603663237b8a4f2f3a7ac50d59e727d8c Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:12:02 -0400 Subject: [PATCH 2/7] docs: address review scope and feature placement for #1031 --- docs/CURRENT-STATE.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 47ce15815..709613bad 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -97,6 +97,11 @@ lists at 25 rows by default. Voice offers the same queries next to its app actions: the session lists them, and the browser runs them through the same catalog, loaded on first use. See [tools and the MCP server](TOOLS.md). +Catalog calls check their cancellation signal before tool execution and after +the tool resolves, including nested calls. Cancellation discards late catalog +results without promising rollback of completed actions or invalidating correct +static-source caches. + ## Cyber HUD — September 30, 2026 Display > HUD > Layout includes Cyber, also available through the HUD voice @@ -5310,8 +5315,3 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. - - -### fix(tools): stop cancelled calls before execution and discard late results - -Check cancellation before invoking the tool and after it resolves, including nested calls that share the same signal. This does not promise rollback of an action already completed. From fffbe67b6685ad37e6c31eeebdce2a41b1eab975 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:23:37 -0400 Subject: [PATCH 3/7] fix(tools): reject cancellation after async interception --- src/tools/catalog-cancellation.test.mjs | 37 +++++++++++++++++++++++++ src/tools/catalog.js | 12 ++++++-- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/tools/catalog-cancellation.test.mjs b/src/tools/catalog-cancellation.test.mjs index a374bd71e..a4a2b7c9d 100644 --- a/src/tools/catalog-cancellation.test.mjs +++ b/src/tools/catalog-cancellation.test.mjs @@ -39,3 +39,40 @@ test('cancelled catalog calls cannot execute or return late results', async () = await assert.rejects(pending, { name: 'AbortError' }); assert.equal(calls, 1); }); + +test('cancellation during an awaited interceptor cannot publish its late result', async () => { + const controller = new AbortController(); + const reason = new Error('interceptor caller canceled'); + let calls = 0; + const action = defineTool({ + name: 'fixture_intercepted_action', + kind: 'action', + title: 'Fixture', + description: 'Offline fixture', + inputSchema: { type: 'object' }, + run: async () => { + calls++; + return { summary: 'completed action', data: {} }; + }, + }); + const catalog = composeCatalog({ + tools: [action], + interceptors: [ + async (call, next) => { + const result = await next(call); + await Promise.resolve(); + controller.abort(reason); + return result; + }, + ], + }); + await assert.rejects( + catalog.call( + 'fixture_intercepted_action', + {}, + { signal: controller.signal }, + ), + (error) => error === reason, + ); + assert.equal(calls, 1, 'completed actions are not rolled back or rerun'); +}); diff --git a/src/tools/catalog.js b/src/tools/catalog.js index 6faf82ce5..ea0e37460 100644 --- a/src/tools/catalog.js +++ b/src/tools/catalog.js @@ -169,13 +169,21 @@ export function composeCatalog({ ); const index = new Map(available.map((tool) => [tool.name, tool])); - const invoke = (name, args, signal, parent) => { + const invoke = async (name, args, signal, parent) => { + signal?.throwIfAborted(); const tool = index.get(name); if (!tool) return Promise.reject( new ToolError('unsupported', `No tool named ${name} is available`), ); - return chain({ tool, args, signal, ...(parent ? { parent } : {}) }); + const result = await chain({ + tool, + args, + signal, + ...(parent ? { parent } : {}), + }); + signal?.throwIfAborted(); + return result; }; const execute = async ({ tool, args, signal }) => { signal?.throwIfAborted(); From aacb409fbf74a8add2c6b57ab9a2c4326f8a7376 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:32:52 -0400 Subject: [PATCH 4/7] fix(tools): limit cancellation guard to pre-execution --- CHANGELOG.md | 2 + docs/CURRENT-STATE.md | 7 +- src/tools/catalog-cancellation.test.mjs | 117 +++++++++--------------- src/tools/catalog.js | 13 +-- 4 files changed, 52 insertions(+), 87 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33ebae897..21b296fdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ - Cancelled tool calls refuse actions before execution and discard results that settle after cancellation. +- Cancelled catalog queries refuse execution when their signal is already aborted. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 709613bad..648fa0584 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -97,10 +97,9 @@ lists at 25 rows by default. Voice offers the same queries next to its app actions: the session lists them, and the browser runs them through the same catalog, loaded on first use. See [tools and the MCP server](TOOLS.md). -Catalog calls check their cancellation signal before tool execution and after -the tool resolves, including nested calls. Cancellation discards late catalog -results without promising rollback of completed actions or invalidating correct -static-source caches. +Catalog calls check their cancellation signal immediately before executing a +tool. Already-cancelled queries do not start; cancellation after execution starts +continues to follow the existing tool and caller behavior. ## Cyber HUD — September 30, 2026 diff --git a/src/tools/catalog-cancellation.test.mjs b/src/tools/catalog-cancellation.test.mjs index a4a2b7c9d..0f666b262 100644 --- a/src/tools/catalog-cancellation.test.mjs +++ b/src/tools/catalog-cancellation.test.mjs @@ -1,78 +1,51 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { composeCatalog, defineTool } from './catalog.js'; -test('cancelled catalog calls cannot execute or return late results', async () => { - let calls = 0, - release, - block = false; - const action = defineTool({ - name: 'fixture_action', - kind: 'action', - title: 'Fixture', - description: 'Offline fixture', - inputSchema: { type: 'object' }, - run: async () => { - calls++; - return block - ? new Promise((r) => (release = r)) - : { summary: 'fixture', data: {} }; - }, - }); - const catalog = composeCatalog({ tools: [action] }); - const c = new AbortController(); - c.abort(); - await assert.rejects( - catalog.call('fixture_action', {}, { signal: c.signal }), - { name: 'AbortError' }, - ); - assert.equal(calls, 0); - block = true; - const running = new AbortController(); - const pending = catalog.call( - 'fixture_action', - {}, - { signal: running.signal }, - ); - while (!release) await Promise.resolve(); - running.abort(); - release({ summary: 'late', data: {} }); - await assert.rejects(pending, { name: 'AbortError' }); - assert.equal(calls, 1); -}); -test('cancellation during an awaited interceptor cannot publish its late result', async () => { - const controller = new AbortController(); - const reason = new Error('interceptor caller canceled'); - let calls = 0; - const action = defineTool({ - name: 'fixture_intercepted_action', - kind: 'action', - title: 'Fixture', - description: 'Offline fixture', - inputSchema: { type: 'object' }, - run: async () => { - calls++; - return { summary: 'completed action', data: {} }; - }, - }); - const catalog = composeCatalog({ - tools: [action], - interceptors: [ - async (call, next) => { - const result = await next(call); - await Promise.resolve(); - controller.abort(reason); - return result; +for (const intercept of [false, true]) { + test(`cancelled query refuses execution${intercept ? ' after an awaited interceptor' : ''}`, async () => { + let calls = 0; + const controller = new AbortController(); + const query = defineTool({ + name: 'fixture_query', + kind: 'query', + title: 'Fixture', + description: 'Offline fixture', + inputSchema: { type: 'object' }, + run: async () => { + calls++; + return { summary: 'fixture', data: {} }; }, - ], + }); + const catalog = composeCatalog({ + tools: [query], + interceptors: intercept + ? [ + async (call, next) => { + await Promise.resolve(); + controller.abort(); + return next(call); + }, + ] + : [], + }); + if (!intercept) controller.abort(); + await assert.rejects( + catalog.call('fixture_query', {}, { signal: controller.signal }), + { name: 'AbortError' }, + ); + assert.equal(calls, 0); + const fresh = composeCatalog({ tools: [query] }); + assert.equal( + ( + await fresh.call( + 'fixture_query', + {}, + { signal: new AbortController().signal }, + ) + ).summary, + 'fixture', + ); + assert.equal(calls, 1); }); - await assert.rejects( - catalog.call( - 'fixture_intercepted_action', - {}, - { signal: controller.signal }, - ), - (error) => error === reason, - ); - assert.equal(calls, 1, 'completed actions are not rolled back or rerun'); -}); +} diff --git a/src/tools/catalog.js b/src/tools/catalog.js index ea0e37460..d617a9f28 100644 --- a/src/tools/catalog.js +++ b/src/tools/catalog.js @@ -169,21 +169,13 @@ export function composeCatalog({ ); const index = new Map(available.map((tool) => [tool.name, tool])); - const invoke = async (name, args, signal, parent) => { - signal?.throwIfAborted(); + const invoke = (name, args, signal, parent) => { const tool = index.get(name); if (!tool) return Promise.reject( new ToolError('unsupported', `No tool named ${name} is available`), ); - const result = await chain({ - tool, - args, - signal, - ...(parent ? { parent } : {}), - }); - signal?.throwIfAborted(); - return result; + return chain({ tool, args, signal, ...(parent ? { parent } : {}) }); }; const execute = async ({ tool, args, signal }) => { signal?.throwIfAborted(); @@ -203,7 +195,6 @@ export function composeCatalog({ } catch (error) { throw fromSourceError(error); } - signal?.throwIfAborted(); if (!result || typeof result.summary !== 'string' || !result.data) throw new TypeError(`${tool.name} returned no summary or data`); if ( From 09018ac753e7edabc94eed09094267d591ee6fa4 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Wed, 14 Oct 2026 15:18:15 -0400 Subject: [PATCH 5/7] docs: drop superseded CHANGELOG bullet The review-driven rewrite added a narrower entry but left the original bullet in place, so the PR carried two entries for one change. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21b296fdf..0a761a798 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,8 +15,6 @@ the five borough cores load and `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is unchanged; extra cameras share it round-robin with the other packs. -- Cancelled tool calls refuse actions before execution and discard results that settle after cancellation. - - Cancelled catalog queries refuse execution when their signal is already aborted. - Treat explicitly current, complete empty vessel coverage as a successful update, From a37a94fba1a214d0c901e032d83d72622bfda8c5 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Wed, 14 Oct 2026 20:13:38 -0400 Subject: [PATCH 6/7] fix(services): retry malformed capability probes after the existing backoff Rebased onto current main with review follow-ups folded in. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 + docs/CURRENT-STATE.md | 3 +- src/annotations/annotationEngine.test.mjs | 4 +- src/services/boundary-probe-recovery.test.mjs | 38 +++++++++++++++++++ src/services/requests.js | 15 +++++--- 5 files changed, 54 insertions(+), 8 deletions(-) create mode 100644 src/services/boundary-probe-recovery.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index a770fbd35..24ed601cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ - Director timeline seeks cross the dateline along the short longitude arc. +- Boundary capability checks recover after unsuccessful or malformed status responses, using the existing retry backoff. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1f794a5b5..d8409281f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4066,7 +4066,8 @@ when its own signal aborts. Configured endpoints have per-instance cooldowns for 406/429, honoring Retry-After; absent delays back off from 30 seconds to five minutes. Empty `elements` is a valid answer. Existing byte, query, concurrency and cache caps -remain. Consumer capability misses never schedule automatic retries. +remain. Malformed capability-probe responses remain unknown and are retried after the existing 30-second backoff; valid configured decisions remain memoized. +Consumer capability misses never schedule automatic retries. Operator-configured Overpass area and focus-footprint queries use body-level `out geom` (with `center` for focus), preserving relation member outlines. diff --git a/src/annotations/annotationEngine.test.mjs b/src/annotations/annotationEngine.test.mjs index 813163d8c..3c4b9165b 100644 --- a/src/annotations/annotationEngine.test.mjs +++ b/src/annotations/annotationEngine.test.mjs @@ -313,7 +313,9 @@ test('retry: a plain HTTP 500 still exhausts the existing 8s/25s transient ladde t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 100_000 }); globalThis.window = { setTimeout: globalThis.setTimeout, clearTimeout: globalThis.clearTimeout }; const requestTimes = []; - globalThis.fetch = async () => { + globalThis.fetch = async (url) => { + if (String(url).endsWith('/status')) + return Response.json({ configured: true }); requestTimes.push(Date.now()); return httpFailure(500); }; diff --git a/src/services/boundary-probe-recovery.test.mjs b/src/services/boundary-probe-recovery.test.mjs new file mode 100644 index 000000000..cc0c47c08 --- /dev/null +++ b/src/services/boundary-probe-recovery.test.mjs @@ -0,0 +1,38 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createApplicationRequestServices } from './requests.js'; +test('unknown capability replies recover after backoff', async (t) => { + t.mock.timers.enable({ apis: ['Date'], now: 100_000 }); + for (const first of [ + () => Response.json({ wrong: true }), + () => new Response('bad', { status: 503 }), + ]) { + let probes = 0; + let queries = 0; + const services = createApplicationRequestServices({ + boundaryProbe: { timeoutMs: 1000, retryMs: 1000 }, + fetchImpl: async (url) => { + if (url.endsWith('/status')) + return ++probes === 1 + ? first() + : Response.json({ configured: false }); + queries++; + return Response.json({ elements: [] }); + }, + }); + assert.deepEqual(await services.boundaries.query('fixture'), []); + assert.deepEqual(await services.boundaries.query('fixture'), []); + assert.equal(probes, 1, 'unknown reply is not re-probed inside backoff'); + t.mock.timers.tick(1000); + assert.equal( + (await services.boundaries.query('fixture')).code, + 'OVERPASS_NOT_CONFIGURED', + ); + assert.equal( + (await services.boundaries.query('fixture')).code, + 'OVERPASS_NOT_CONFIGURED', + ); + assert.equal(probes, 2); + assert.equal(queries, 2); + } +}); diff --git a/src/services/requests.js b/src/services/requests.js index bb0b4cefd..3e3a819eb 100644 --- a/src/services/requests.js +++ b/src/services/requests.js @@ -66,9 +66,9 @@ export function createApplicationRequestServices({ throw new Error(`${label} unavailable (${response.status})`); return response.data; } - // Learn once per page whether the server has an Overpass instance, so an + // Remember valid capability decisions for the page, so an // unconfigured server is never asked (and never answers with an error). - // Discovery is bounded; a failed or timed-out probe is forgotten and not + // Discovery is bounded; failed, malformed or timed-out probes are forgotten and not // retried for a short backoff, during which queries go to the server as // they would against an older server without the probe. let boundaryCapability = null; @@ -79,10 +79,13 @@ export function createApplicationRequestServices({ boundaryCapability = request(`${urls.boundaries}/status`, { signal: AbortSignal.timeout(boundaryProbe.timeoutMs), }).then( - (response) => - response.ok && typeof response.data?.configured === 'boolean' - ? response.data.configured - : null, // an older server: fall back to asking per query + (response) => { + if (response.ok && typeof response.data?.configured === 'boolean') + return response.data.configured; + boundaryCapability = null; + boundaryProbeRetryAt = Date.now() + boundaryProbe.retryMs; + return null; // capability unknown: let this query ask the server + }, (error) => { boundaryCapability = null; if (lifetime?.aborted) throw error; From 19fcc00b3ee3697cec2c814262791bbce0933220 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Wed, 14 Oct 2026 20:15:09 -0400 Subject: [PATCH 7/7] fix(terrain): reject out-of-range points at the proxy boundary Rebased onto current main with review follow-ups folded in. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 ++ docs/CURRENT-STATE.md | 4 ++++ src/data/terrainHeightsProxy.js | 8 +++++++- src/data/terrainHeightsProxy.test.mjs | 11 +++++++++++ src/proxyErrorResponses.test.mjs | 16 ++++++++++++++++ 5 files changed, 40 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a770fbd35..2d15df2d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ - Director timeline seeks cross the dateline along the short longitude arc. +- The terrain proxy refuses out-of-range WGS84 coordinates before upstream resolution for every client. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1f794a5b5..73e521e01 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1192,6 +1192,10 @@ The browser layers and their rendering remain in their existing modules. ## Terrain height cache bound +The terrain proxy parser rejects finite longitudes outside [-180, 180] and +latitudes outside [-90, 90] for every client before upstream resolution. One +out-of-range point rejects the whole batch; inclusive WGS84 bounds remain valid. + The client terrain-height resolver's in-memory cache is bounded at 20 000 entries (`DEFAULT_MAX_CACHE_ENTRIES`, overridable per instance through `createTerrainHeights({ maxCacheEntries })`). Coordinate rounding makes repeated diff --git a/src/data/terrainHeightsProxy.js b/src/data/terrainHeightsProxy.js index bfb058693..9134f9ec6 100644 --- a/src/data/terrainHeightsProxy.js +++ b/src/data/terrainHeightsProxy.js @@ -35,7 +35,13 @@ export function parseTerrainPoints(raw) { if (parts.length !== 2) return null; const lon = Number(parts[0]); const lat = Number(parts[1]); - if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null; + if ( + !Number.isFinite(lon) || + !Number.isFinite(lat) || + Math.abs(lon) > 180 || + Math.abs(lat) > 90 + ) + return null; points.push([lon, lat]); } return points; diff --git a/src/data/terrainHeightsProxy.test.mjs b/src/data/terrainHeightsProxy.test.mjs index f6b53de9a..50e45b732 100644 --- a/src/data/terrainHeightsProxy.test.mjs +++ b/src/data/terrainHeightsProxy.test.mjs @@ -3,6 +3,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { + parseTerrainPoints, fetchTerrainChunkWithRetry, resolveTerrainHeightRequest, terrainPointKey, @@ -227,3 +228,13 @@ test('malformed height objects remain refresh failures, not absent readings', as assert.equal(response.cacheChanged, false); } }); + +test('terrain parser rejects out-of-range WGS84 points and accepts inclusive bounds', () => { + for (const value of ['181,0', '-181,0', '0,91', '0,-91', '1,2;0,91']) + assert.equal(parseTerrainPoints(value), null, value); + assert.deepEqual(parseTerrainPoints('-180,-90;180,90;0,0'), [ + [-180, -90], + [180, 90], + [0, 0], + ]); +}); diff --git a/src/proxyErrorResponses.test.mjs b/src/proxyErrorResponses.test.mjs index 2b45a3b9e..c6a7269ab 100644 --- a/src/proxyErrorResponses.test.mjs +++ b/src/proxyErrorResponses.test.mjs @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import path from 'node:path'; +import { parseTerrainPoints } from './data/terrainHeightsProxy.js'; import { readResponseTextCapped, coalesceProxyRequest } from './sources/httpBody.js'; const source = ['local.js', 'common/http.js', 'aircraft/enrichment.js', 'terrain.js', 'space/celestrak.js', 'space/launch-library.js', '../../src/data/spaceProviderRequests.js'] @@ -172,3 +173,18 @@ test('ADSBDB retains validation and missing-aircraft semantics', async () => { assert.equal(absent.status, 200); assert.deepEqual(JSON.parse(absent.body), { found: false }); }); + +for (const preview of [false, true]) { + test(`terrain server rejects out-of-range points before upstream resolution in ${preview ? 'preview' : 'development'}`, async () => { + let calls = 0; + const app = fixture('terrainHeightsProxy', { parseTerrainPoints, + resolveTerrainHeightRequest: async () => { calls++; return { status: 200, body: { results: [] } }; }, + }, preview); + for (const points of ['181,0', '-181,0', '0,91', '0,-91', '1,2;0,91']) { + assert.equal((await app.request('/?points=' + encodeURIComponent(points))).status, 400); + } + assert.equal(calls, 0); + assert.equal((await app.request('/?points=-180,-90;180,90')).status, 200); + assert.equal(calls, 1); + }); +}