From b09db551495e2c9e51c8ff30c903f9e1a4c851c3 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Mon, 28 Sep 2026 11:53:13 +0700 Subject: [PATCH 1/6] fix(server): declare the method contract on the three read-only query proxies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /api/opensky, /api/adsblol/mil and /api/route answered any verb with the same body while ignoring the request entirely. Measured against a running server before the change: POST /api/opensky returned 200 with 833,759 bytes of live state vectors, POST /api/adsblol/mil returned 47,372 bytes. Their siblings do not: /api/geocode, /api/weather and /api/transit answer 405, and /api/overpass is deliberately POST-only. These three are read-only query proxies, so they now answer 405 to anything but GET, using the wording and shape /api/geocode already uses. The refusal comes first, before rate limiting and before any upstream call, which the tests assert by stubbing fetch to throw. Scope, stated plainly: this is a consistency and intent fix. No rate limiter was being bypassed — /api/route already limits every request regardless of verb, and the two aircraft routes have no limiter at all, only 9s and 12s server-side caches that bound upstream cost for any method. One existing fixture in src/search/geospatial.test.mjs built a request without a method, which a real Node request always has; it now sets GET. Verified on a running server after the change: GET still 200 on all three, POST 405 on all three, /api/overpass and the other proxies unchanged. Suite goes from 5090 to 5092 tests, 0 failures. --- docs/CURRENT-STATE.md | 1 + server/providers/aircraft/adsb-lol.js | 7 +++++++ server/providers/aircraft/opensky.js | 8 ++++++++ server/providers/places/routes.js | 7 +++++++ src/search/geospatial.test.mjs | 6 +++++- src/tooling/liveProviders.test.mjs | 23 +++++++++++++++++++++++ src/tooling/placeProviders.test.mjs | 19 +++++++++++++++++++ 7 files changed, 70 insertions(+), 1 deletion(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 22ce1eb3b..15e159ac8 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4072,6 +4072,7 @@ are omitted rather than framing the wrong part of the globe. - `/api/overpass` is bounded by body/response caps, per-client/global rate limits, concurrency limits, operator-configured upstreams, in-flight dedupe, cache bounds, and static validation that every selector is spatially bounded. - `/api/military-installations` uses an independent limiter with the same 90-per-client/300-global one-minute bounds, so viewport installation refreshes never consume `/api/overpass` annotation capacity. - `/api/route` proxies bounded OSRM route requests for annotation routes, with profile allowlisting, distance caps, response caps, caching, and sanitized "no route found" errors. +- `/api/opensky`, `/api/adsblol/mil`, and `/api/route` are read-only query proxies and answer `405 Method Not Allowed` to anything but `GET`, as `/api/geocode` and the other query proxies already did. The refusal precedes rate limiting and any upstream call. - Track endpoints: `/api/ais-live/track?mmsi=` (server-accumulated ring buffers; sub-route handled before the rows snapshot), `/api/opensky-track?icao24=` (OAuth, 60s cache, sanitized errors, independent OpenSky credit bucket), `/api/adsblol/trace?hex=` (60s cache, 5MB cap, ODbL attribution required in UI). - Realtime debug logs redact API keys, bearer tokens, client secrets, and image data URLs before writing to disk; request bodies are size-capped. - `/api/realtime/debug-log` enforces an always-on 120-per-client/400-global one-minute limiter (not the opt-in `GEV_RATELIMIT_OPENAI_PER_MIN` bucket the cost-bearing OpenAI routes share), appends asynchronously through a serialized queue, and rotates `realtime-conversations.jsonl` at 32 MB keeping one prior generation, so the sink is bounded at twice that regardless of session length. A malformed record answers 400 and a failed write 500, both with a fixed message. diff --git a/server/providers/aircraft/adsb-lol.js b/server/providers/aircraft/adsb-lol.js index fc5ab0bd3..80140bb56 100644 --- a/server/providers/aircraft/adsb-lol.js +++ b/server/providers/aircraft/adsb-lol.js @@ -68,6 +68,13 @@ export function adsbLolProxy() { const installMiddleware = (server) => { server.middlewares.use('/api/adsblol/mil', async (req, res) => { + // Read-only snapshot endpoint; match the method contract its sibling + // query proxies already declare instead of serving every verb alike. + if (req.method !== 'GET') { + res.writeHead(405, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Method Not Allowed' })); + return; + } try { const now = Date.now(); if (_cache && now - _cacheAt < CACHE_MS) { diff --git a/server/providers/aircraft/opensky.js b/server/providers/aircraft/opensky.js index 612d01e11..aa0dad268 100644 --- a/server/providers/aircraft/opensky.js +++ b/server/providers/aircraft/opensky.js @@ -350,6 +350,14 @@ function openSkySourceIsStale(sourceEpochMs, now = Date.now()) { export function openSkyProxy() { const installMiddleware = (server) => { server.middlewares.use('/api/opensky', async (req, res) => { + // Read-only snapshot endpoint: state the method contract the way + // /api/geocode and the other query proxies state it, rather than + // answering any verb with the same body and ignoring the request body. + if (req.method !== 'GET') { + res.writeHead(405, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Method Not Allowed' })); + return; + } try { const requestedMode = normalizeOpenSkyAuthMode( process.env.OPENSKY_AUTH_MODE, diff --git a/server/providers/places/routes.js b/server/providers/places/routes.js index 42e24ad32..8e837fcd4 100644 --- a/server/providers/places/routes.js +++ b/server/providers/places/routes.js @@ -227,6 +227,13 @@ export function installRouteMiddleware( // only shape that asks the upstream for them. A response WITHOUT steps is // byte for byte what this endpoint has always returned. middlewares.use('/api/route', async (req, res) => { + // The documented shape above is a GET with query parameters; say so, + // the way the other query proxies do, instead of answering any verb. + if (req.method !== 'GET') { + res.writeHead(405, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Method Not Allowed' })); + return; + } const fail = (msg) => { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: msg })); diff --git a/src/search/geospatial.test.mjs b/src/search/geospatial.test.mjs index be846bb36..dbedbe53f 100644 --- a/src/search/geospatial.test.mjs +++ b/src/search/geospatial.test.mjs @@ -209,7 +209,11 @@ test('routing middleware uses configured OSRM servers while retaining request bo const request = async (query) => { let payload; await handler( - { url: `/?${query}`, socket: { remoteAddress: '127.0.0.1' } }, + { + url: `/?${query}`, + method: 'GET', + socket: { remoteAddress: '127.0.0.1' }, + }, { writeHead() {}, end(body) { diff --git a/src/tooling/liveProviders.test.mjs b/src/tooling/liveProviders.test.mjs index 38953eff9..c25c445d4 100644 --- a/src/tooling/liveProviders.test.mjs +++ b/src/tooling/liveProviders.test.mjs @@ -381,3 +381,26 @@ test('track backfill proxy returns 502 on an oversized upstream body and caches assert.equal(res2.statusCode, 502); assert.equal(callCount, 1); }); + +test('the aircraft snapshot routes decline a non-GET before any upstream work', async (t) => { + // Both routes answered every verb with the full snapshot, unlike the query + // proxies beside them, so nothing said whether a body was read or ignored. + // A stubbed fetch that throws proves the refusal happens before the handler + // reaches upstream, rather than after paying for the work and discarding it. + t.mock.method(globalThis, 'fetch', async (url) => { + throw new Error(`upstream must not be reached: ${url}`); + }); + const cases = [ + [install(providers.openSkyProxy()), '/api/opensky'], + [install(providers.adsbLolProxy()), '/api/adsblol/mil'], + ]; + for (const [request, route] of cases) { + for (const method of ['POST', 'PUT', 'DELETE', 'PATCH']) { + const response = await request(route, '/', method); + assert.equal(response.statusCode, 405, `${method} ${route}`); + assert.deepEqual(JSON.parse(response.body), { + error: 'Method Not Allowed', + }); + } + } +}); diff --git a/src/tooling/placeProviders.test.mjs b/src/tooling/placeProviders.test.mjs index 78d311369..968c5a9fa 100644 --- a/src/tooling/placeProviders.test.mjs +++ b/src/tooling/placeProviders.test.mjs @@ -482,3 +482,22 @@ test('the upstream host is pinned: a redirect is refused, not followed', async ( await request('/api/route', '?profile=car&coords=-97,30;-97.01,30.01'); assert.deepEqual(seen, ['error'], 'a redirect would escape the pinned host'); }); + +test('the routing proxy declines a non-GET before rate limiting or upstream', async (t) => { + // The documented shape is a GET with query parameters, but every verb was + // answered alike. Refuse first: a stubbed fetch that throws shows the + // rejection costs neither an upstream call nor a rate-limit slot. + const request = install(installRouteMiddleware); + t.mock.method(globalThis, 'fetch', async () => { + throw new Error('upstream must not be reached'); + }); + for (const method of ['POST', 'PUT', 'DELETE', 'PATCH']) { + const response = await request( + '/api/route', + '?profile=car&coords=-97,30;-97.01,30.01', + method, + ); + assert.equal(response.statusCode, 405, method); + assert.deepEqual(response.body, { error: 'Method Not Allowed' }); + } +}); From fb6b4e34f0aab85c715146739152d753d5a3efa8 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Mon, 28 Sep 2026 13:16:17 +0700 Subject: [PATCH 2/6] docs: changelog entry for the read-only proxy method contract (#794) --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58103d04d..7885f0131 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +- The read-only query proxies state their method contract. `/api/opensky`, + `/api/adsblol/mil` and `/api/route` answered any verb with the same body + while ignoring the request; they now answer `405 Method Not Allowed` to + anything but `GET`, the way `/api/geocode` and the other query proxies + already did, and declare `Allow: GET` with it the way `/api/radio/stations` + does. The refusal precedes rate limiting and any upstream call + (daikaginza, #794). + - Public Overpass instances are no longer used by default. Street Traffic roads come from TomTom flow tiles, OpenFreeMap vector tiles, or both, chosen on the layer row (TomTom / OSM / Hybrid) or with `?trafficRoads=`. With a From 2e5dfce1af9b07664005bd1bd77e5bcb4ccf0ba4 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Tue, 29 Sep 2026 10:56:20 +0700 Subject: [PATCH 3/6] fix(server): declare Allow: GET on the new 405 responses RFC 9110 section 15.5.6 requires a 405 to name the methods it accepts, and /api/radio/stations already does this in-tree. Thanks to kvnloo for catching it on review. The same gap on the pre-existing handlers is tracked in #809 and is deliberately left alone here. --- docs/CURRENT-STATE.md | 2 +- server/providers/aircraft/adsb-lol.js | 5 ++++- server/providers/aircraft/opensky.js | 5 ++++- server/providers/places/routes.js | 2 +- src/tooling/liveProviders.test.mjs | 3 +++ src/tooling/placeProviders.test.mjs | 2 ++ 6 files changed, 15 insertions(+), 4 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 15e159ac8..6e3039920 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4072,7 +4072,7 @@ are omitted rather than framing the wrong part of the globe. - `/api/overpass` is bounded by body/response caps, per-client/global rate limits, concurrency limits, operator-configured upstreams, in-flight dedupe, cache bounds, and static validation that every selector is spatially bounded. - `/api/military-installations` uses an independent limiter with the same 90-per-client/300-global one-minute bounds, so viewport installation refreshes never consume `/api/overpass` annotation capacity. - `/api/route` proxies bounded OSRM route requests for annotation routes, with profile allowlisting, distance caps, response caps, caching, and sanitized "no route found" errors. -- `/api/opensky`, `/api/adsblol/mil`, and `/api/route` are read-only query proxies and answer `405 Method Not Allowed` to anything but `GET`, as `/api/geocode` and the other query proxies already did. The refusal precedes rate limiting and any upstream call. +- `/api/opensky`, `/api/adsblol/mil`, and `/api/route` are read-only query proxies and answer `405 Method Not Allowed` to anything but `GET`, as `/api/geocode` and the other query proxies already did, declaring `Allow: GET` with it as `/api/radio/stations` does. The refusal precedes rate limiting and any upstream call. - Track endpoints: `/api/ais-live/track?mmsi=` (server-accumulated ring buffers; sub-route handled before the rows snapshot), `/api/opensky-track?icao24=` (OAuth, 60s cache, sanitized errors, independent OpenSky credit bucket), `/api/adsblol/trace?hex=` (60s cache, 5MB cap, ODbL attribution required in UI). - Realtime debug logs redact API keys, bearer tokens, client secrets, and image data URLs before writing to disk; request bodies are size-capped. - `/api/realtime/debug-log` enforces an always-on 120-per-client/400-global one-minute limiter (not the opt-in `GEV_RATELIMIT_OPENAI_PER_MIN` bucket the cost-bearing OpenAI routes share), appends asynchronously through a serialized queue, and rotates `realtime-conversations.jsonl` at 32 MB keeping one prior generation, so the sink is bounded at twice that regardless of session length. A malformed record answers 400 and a failed write 500, both with a fixed message. diff --git a/server/providers/aircraft/adsb-lol.js b/server/providers/aircraft/adsb-lol.js index 80140bb56..1e5565832 100644 --- a/server/providers/aircraft/adsb-lol.js +++ b/server/providers/aircraft/adsb-lol.js @@ -71,7 +71,10 @@ export function adsbLolProxy() { // Read-only snapshot endpoint; match the method contract its sibling // query proxies already declare instead of serving every verb alike. if (req.method !== 'GET') { - res.writeHead(405, { 'Content-Type': 'application/json' }); + res.writeHead(405, { + 'Content-Type': 'application/json', + Allow: 'GET', + }); res.end(JSON.stringify({ error: 'Method Not Allowed' })); return; } diff --git a/server/providers/aircraft/opensky.js b/server/providers/aircraft/opensky.js index aa0dad268..679548300 100644 --- a/server/providers/aircraft/opensky.js +++ b/server/providers/aircraft/opensky.js @@ -354,7 +354,10 @@ export function openSkyProxy() { // /api/geocode and the other query proxies state it, rather than // answering any verb with the same body and ignoring the request body. if (req.method !== 'GET') { - res.writeHead(405, { 'Content-Type': 'application/json' }); + res.writeHead(405, { + 'Content-Type': 'application/json', + Allow: 'GET', + }); res.end(JSON.stringify({ error: 'Method Not Allowed' })); return; } diff --git a/server/providers/places/routes.js b/server/providers/places/routes.js index 8e837fcd4..2c67f1f5c 100644 --- a/server/providers/places/routes.js +++ b/server/providers/places/routes.js @@ -230,7 +230,7 @@ export function installRouteMiddleware( // The documented shape above is a GET with query parameters; say so, // the way the other query proxies do, instead of answering any verb. if (req.method !== 'GET') { - res.writeHead(405, { 'Content-Type': 'application/json' }); + res.writeHead(405, { 'Content-Type': 'application/json', Allow: 'GET' }); res.end(JSON.stringify({ error: 'Method Not Allowed' })); return; } diff --git a/src/tooling/liveProviders.test.mjs b/src/tooling/liveProviders.test.mjs index c25c445d4..856a528a2 100644 --- a/src/tooling/liveProviders.test.mjs +++ b/src/tooling/liveProviders.test.mjs @@ -398,6 +398,9 @@ test('the aircraft snapshot routes decline a non-GET before any upstream work', for (const method of ['POST', 'PUT', 'DELETE', 'PATCH']) { const response = await request(route, '/', method); assert.equal(response.statusCode, 405, `${method} ${route}`); + // RFC 9110 section 15.5.6 requires a 405 to name the methods it does + // accept, which is what `/api/radio/stations` already does in-tree. + assert.equal(response.headers.allow, 'GET', `${method} ${route}`); assert.deepEqual(JSON.parse(response.body), { error: 'Method Not Allowed', }); diff --git a/src/tooling/placeProviders.test.mjs b/src/tooling/placeProviders.test.mjs index 968c5a9fa..afc323efc 100644 --- a/src/tooling/placeProviders.test.mjs +++ b/src/tooling/placeProviders.test.mjs @@ -498,6 +498,8 @@ test('the routing proxy declines a non-GET before rate limiting or upstream', as method, ); assert.equal(response.statusCode, 405, method); + // RFC 9110 section 15.5.6 requires a 405 to name the methods it accepts. + assert.equal(response.headers.allow, 'GET', method); assert.deepEqual(response.body, { error: 'Method Not Allowed' }); } }); From d3370d6bc4ce5f264fd29eece459052e6565cb1a Mon Sep 17 00:00:00 2001 From: daikaginza Date: Sun, 18 Oct 2026 13:36:40 +0700 Subject: [PATCH 4/6] docs(changelog): move the entry into a changelog.d fragment Main now takes pending entries as changelog.d/-.md files (#787), so the entry leaves CHANGELOG.md for changelog.d/794-api-method-guard.md. --- changelog.d/794-api-method-guard.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 changelog.d/794-api-method-guard.md diff --git a/changelog.d/794-api-method-guard.md b/changelog.d/794-api-method-guard.md new file mode 100644 index 000000000..530be060e --- /dev/null +++ b/changelog.d/794-api-method-guard.md @@ -0,0 +1,7 @@ +- The read-only query proxies state their method contract. `/api/flights`, + `/api/military` and `/api/route` answered any verb with the same body + while ignoring the request; they now answer `405 Method Not Allowed` to + anything but `GET`, the way `/api/geocode` and the other query proxies + already did, and declare `Allow: GET` with it the way `/api/radio/stations` + does. The refusal precedes rate limiting and any upstream call + (daikaginza, #794). From c3738c0fdb8dfeb0e2add30bbbd79f03915c2af5 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Mon, 9 Nov 2026 19:07:14 +0400 Subject: [PATCH 5/6] docs(server): tighten the method-guard comments and changelog --- changelog.d/794-api-method-guard.md | 11 ++++------- docs/CURRENT-STATE.md | 2 +- server/providers/aircraft/adsb-lol.js | 4 +--- server/providers/aircraft/opensky.js | 5 +---- server/providers/places/routes.js | 2 -- src/tooling/liveProviders.test.mjs | 13 ++++--------- src/tooling/placeProviders.test.mjs | 6 ++---- 7 files changed, 13 insertions(+), 30 deletions(-) diff --git a/changelog.d/794-api-method-guard.md b/changelog.d/794-api-method-guard.md index 530be060e..97801e091 100644 --- a/changelog.d/794-api-method-guard.md +++ b/changelog.d/794-api-method-guard.md @@ -1,7 +1,4 @@ -- The read-only query proxies state their method contract. `/api/flights`, - `/api/military` and `/api/route` answered any verb with the same body - while ignoring the request; they now answer `405 Method Not Allowed` to - anything but `GET`, the way `/api/geocode` and the other query proxies - already did, and declare `Allow: GET` with it the way `/api/radio/stations` - does. The refusal precedes rate limiting and any upstream call - (daikaginza, #794). +- Answer `405 Method Not Allowed` with `Allow: GET` to anything but `GET` on + `/api/flights`, `/api/military` and `/api/route`, as the other query proxies + do. They answered every verb with the same body. The refusal comes before + rate limiting and any upstream call (daikaginza, #794). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f2569f363..6a3b07c5d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -6491,7 +6491,7 @@ apply to `public/` assets or built-preview serving. - `/api/overpass` is bounded by body/response caps, per-client/global rate limits, concurrency limits, operator-configured upstreams, in-flight dedupe, cache bounds, and static validation that every selector is spatially bounded. - `/api/military-installations` uses an independent limiter with the same 90-per-client/300-global one-minute bounds, so viewport installation refreshes never consume `/api/overpass` annotation capacity. - `/api/route` proxies bounded OSRM route requests for annotation routes, with profile allowlisting, distance caps, response caps, caching, and sanitized "no route found" errors. -- `/api/flights`, `/api/military`, and `/api/route` are read-only query proxies and answer `405 Method Not Allowed` to anything but `GET`, as `/api/geocode` and the other query proxies already did, declaring `Allow: GET` with it as `/api/radio/stations` does. The refusal precedes rate limiting and any upstream call. +- `/api/flights`, `/api/military`, and `/api/route` answer `405 Method Not Allowed` with `Allow: GET` to anything but `GET`, before rate limiting and any upstream call. - `/api/flights` negotiates gzip. The cached global snapshot and the regional adsb.lol fallback are each gzipped once per refresh (about 860 KB becomes about 250 KB). Clients that offer gzip get that frame; other clients, and bodies under 1 KiB or that gzip cannot shrink, get identity bytes. Both answers carry `Vary: Accept-Encoding`. Uncached bodies, such as auth failures, are never sent under a cached frame's encoding. - Track endpoints: `/api/vessels/track?mmsi=` (server-accumulated ring buffers; sub-route handled before the rows snapshot), `/api/flights/track?icao24=` (OAuth, 60s cache, sanitized errors, independent OpenSky credit bucket), `/api/military/track?hex=` (400 for an invalid hex, otherwise `503 history_unavailable` with `Retry-After: 1800` and `Cache-Control: no-store`, no upstream request; military tracking uses its locally accumulated trail). - Realtime debug logs redact API keys, bearer tokens, client secrets, and image data URLs before writing to disk; request bodies are size-capped. diff --git a/server/providers/aircraft/adsb-lol.js b/server/providers/aircraft/adsb-lol.js index 80aacc7c4..6bdc65e9f 100644 --- a/server/providers/aircraft/adsb-lol.js +++ b/server/providers/aircraft/adsb-lol.js @@ -83,9 +83,7 @@ export function adsbLolProxy() { // This mount also matches its /track route, served by its own handler. if (new URL(req.url || '/', 'http://localhost').pathname !== '/') return next(); - // Read-only snapshot endpoint; match the method contract its sibling - // query proxies already declare instead of serving every verb alike. - // Checked after the /track hand-off, so that route keeps its own. + // After the /track hand-off, so /track keeps its own method handling. if (req.method !== 'GET') { res.writeHead(405, { 'Content-Type': 'application/json', diff --git a/server/providers/aircraft/opensky.js b/server/providers/aircraft/opensky.js index 0a23978a5..1cc30a482 100644 --- a/server/providers/aircraft/opensky.js +++ b/server/providers/aircraft/opensky.js @@ -496,10 +496,7 @@ export function openSkyProxy() { // This mount also matches its /track route, served by its own handler. if (new URL(req.url || '/', 'http://localhost').pathname !== '/') return next(); - // Read-only snapshot endpoint: state the method contract the way - // /api/geocode and the other query proxies state it, rather than - // answering any verb with the same body and ignoring the request body. - // Checked after the /track hand-off, so that route keeps its own. + // After the /track hand-off, so /track keeps its own method handling. if (req.method !== 'GET') { res.writeHead(405, { 'Content-Type': 'application/json', diff --git a/server/providers/places/routes.js b/server/providers/places/routes.js index 2c67f1f5c..7b0a81449 100644 --- a/server/providers/places/routes.js +++ b/server/providers/places/routes.js @@ -227,8 +227,6 @@ export function installRouteMiddleware( // only shape that asks the upstream for them. A response WITHOUT steps is // byte for byte what this endpoint has always returned. middlewares.use('/api/route', async (req, res) => { - // The documented shape above is a GET with query parameters; say so, - // the way the other query proxies do, instead of answering any verb. if (req.method !== 'GET') { res.writeHead(405, { 'Content-Type': 'application/json', Allow: 'GET' }); res.end(JSON.stringify({ error: 'Method Not Allowed' })); diff --git a/src/tooling/liveProviders.test.mjs b/src/tooling/liveProviders.test.mjs index e40e4079c..df71ec9a7 100644 --- a/src/tooling/liveProviders.test.mjs +++ b/src/tooling/liveProviders.test.mjs @@ -586,10 +586,7 @@ test('track backfill proxy returns 502 on an oversized upstream body and caches }); test('the aircraft snapshot routes decline a non-GET before any upstream work', async (t) => { - // Both routes answered every verb with the full snapshot, unlike the query - // proxies beside them, so nothing said whether a body was read or ignored. - // A stubbed fetch that throws proves the refusal happens before the handler - // reaches upstream, rather than after paying for the work and discarding it. + // A fetch that throws proves the refusal comes before any upstream call. t.mock.method(globalThis, 'fetch', async (url) => { throw new Error(`upstream must not be reached: ${url}`); }); @@ -601,8 +598,7 @@ test('the aircraft snapshot routes decline a non-GET before any upstream work', for (const method of ['POST', 'PUT', 'DELETE', 'PATCH']) { const response = await request(route, '/', method); assert.equal(response.statusCode, 405, `${method} ${route}`); - // RFC 9110 section 15.5.6 requires a 405 to name the methods it does - // accept, which is what `/api/radio/stations` already does in-tree. + // RFC 9110 section 15.5.6: a 405 names the methods it accepts. assert.equal(response.headers.allow, 'GET', `${method} ${route}`); assert.deepEqual(JSON.parse(response.body), { error: 'Method Not Allowed', @@ -612,9 +608,8 @@ test('the aircraft snapshot routes decline a non-GET before any upstream work', }); test('the snapshot method check hands each /track route on to its own handler', async () => { - // Each snapshot mount is a prefix of its /track route, so the snapshot - // handler sees /track requests first and must hand them on before it - // judges the method; otherwise the 405 would answer for the other route. + // The snapshot mount also matches /track, so it must hand /track on + // before it checks the method. for (const [plugin, route] of [ [providers.openSkyProxy(), '/api/flights'], [providers.adsbLolProxy(), '/api/military'], diff --git a/src/tooling/placeProviders.test.mjs b/src/tooling/placeProviders.test.mjs index 4b728b6ac..4ae9558ff 100644 --- a/src/tooling/placeProviders.test.mjs +++ b/src/tooling/placeProviders.test.mjs @@ -502,9 +502,7 @@ test('the upstream host is pinned: a redirect is refused, not followed', async ( }); test('the routing proxy declines a non-GET before rate limiting or upstream', async (t) => { - // The documented shape is a GET with query parameters, but every verb was - // answered alike. Refuse first: a stubbed fetch that throws shows the - // rejection costs neither an upstream call nor a rate-limit slot. + // A fetch that throws proves the refusal comes before any upstream call. const request = install(installRouteMiddleware); t.mock.method(globalThis, 'fetch', async () => { throw new Error('upstream must not be reached'); @@ -516,7 +514,7 @@ test('the routing proxy declines a non-GET before rate limiting or upstream', as method, ); assert.equal(response.statusCode, 405, method); - // RFC 9110 section 15.5.6 requires a 405 to name the methods it accepts. + // RFC 9110 section 15.5.6: a 405 names the methods it accepts. assert.equal(response.headers.allow, 'GET', method); assert.deepEqual(response.body, { error: 'Method Not Allowed' }); } From cdf52ab290497bda4ff23cc00cfba156524e5389 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Mon, 9 Nov 2026 19:07:14 +0400 Subject: [PATCH 6/6] test(server): drop unused fields from the /track hand-off stub --- src/tooling/liveProviders.test.mjs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/tooling/liveProviders.test.mjs b/src/tooling/liveProviders.test.mjs index df71ec9a7..070a6f997 100644 --- a/src/tooling/liveProviders.test.mjs +++ b/src/tooling/liveProviders.test.mjs @@ -620,8 +620,6 @@ test('the snapshot method check hands each /track route on to its own handler', }); const res = { statusCode: 200, - headers: {}, - setHeader() {}, writeHead(s) { this.statusCode = s; },