mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
Merge pull request #1264 from bilawalsidhu/codex/fix-overpass-request-admission
fix(overpass): bound request validation and reject cross-site POSTs
This commit is contained in:
13 files changed
+576
-66
No files matched your search
@@ -0,0 +1,9 @@
|
|||||||
|
- Prevent malformed Overpass filters from blocking the local server, and reject
|
||||||
|
cross-site or excessive query requests before reading their bodies
|
||||||
|
(asturksever, #1264; fixes #1248).
|
||||||
|
- Release unread responses from the optional Google-token probe so keyless
|
||||||
|
browser validation can settle; the Overpass gate recognizes only that probe's
|
||||||
|
expected 404 as an unavailable capability (asturksever, #1264).
|
||||||
|
- Make tracking arrival checks respect the aircraft update cadence while checking
|
||||||
|
orientation on the first reveal, avoiding false failures at faster render
|
||||||
|
rates (asturksever, #1264).
|
||||||
+17
-6
@@ -2799,11 +2799,16 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
|||||||
> first later successful fetch. Smaller chunks reduce timeout risk; complete
|
> first later successful fetch. Smaller chunks reduce timeout risk; complete
|
||||||
> camera batches still wait for their sequential requests, and unresolved
|
> camera batches still wait for their sequential requests, and unresolved
|
||||||
> placement continues to use the existing prior until real heights arrive.
|
> placement continues to use the existing prior until real heights arrive.
|
||||||
> - **Overpass cache admission:** `/api/overpass` parses and sanitizes requests,
|
> - **Overpass cache admission:** `/api/overpass` refuses cross-site and forwarded
|
||||||
> then checks fresh memory, identical in-flight work, and fresh disk entries
|
> POSTs through the shared local-request gate, then limits requests to 300/min
|
||||||
> before invoking its local 90/min limiter. Cache and single-flight responses
|
> per socket peer and 900/min globally before reading or validating bodies.
|
||||||
> therefore do not spend quota; upstream-bound misses retain the existing
|
> Tag-filter brackets are scanned once; nested or unmatched brackets return 400.
|
||||||
> limiter, configured-upstream, stale, and sanitization behavior. An unconfigured transport serves dated stale data or a non-retryable capability error.
|
> It then checks fresh memory, identical in-flight work, and fresh disk entries
|
||||||
|
> before invoking its separate local 90/min upstream limiter. Cache and
|
||||||
|
> single-flight responses therefore do not spend upstream quota; upstream-bound
|
||||||
|
> misses retain the existing limiter, configured-upstream, stale, and sanitization
|
||||||
|
> behavior. An unconfigured transport serves dated stale data or a non-retryable
|
||||||
|
> capability error.
|
||||||
> - **CCTV world-click focus:** clicking an in-world CCTV icon or ambient card
|
> - **CCTV world-click focus:** clicking an in-world CCTV icon or ambient card
|
||||||
> activates it and routes the camera flight through the panel FOCUS policy.
|
> activates it and routes the camera flight through the panel FOCUS policy.
|
||||||
> Aircraft/satellite tracking releases outside cockpit; cockpit retains the
|
> Aircraft/satellite tracking releases outside cockpit; cockpit retains the
|
||||||
@@ -4619,6 +4624,12 @@ Retry-After; absent delays back off from 30 seconds to five minutes. Empty
|
|||||||
remain. Malformed capability-probe responses remain unknown and are retried after the existing 30-second backoff; valid configured decisions remain memoized.
|
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.
|
Consumer capability misses never schedule automatic retries.
|
||||||
|
|
||||||
|
Keyless browser validation also probes the optional Google tiles-token route.
|
||||||
|
The token client cancels rejected response bodies before returning unavailable,
|
||||||
|
so they do not remain pending in browser network observation. The Overpass gate
|
||||||
|
accepts only that same-origin route's expected 404 console diagnostic; other
|
||||||
|
HTTP and JavaScript errors still fail acceptance.
|
||||||
|
|
||||||
Operator-configured Overpass area and focus-footprint queries use body-level
|
Operator-configured Overpass area and focus-footprint queries use body-level
|
||||||
`out geom` (with `center` for focus), preserving relation member outlines.
|
`out geom` (with `center` for focus), preserving relation member outlines.
|
||||||
Member geometry is printed only inside a box around the point (6 km for
|
Member geometry is printed only inside a box around the point (6 km for
|
||||||
@@ -5067,7 +5078,7 @@ pins and share links already supply complete boxes.
|
|||||||
- Fleet model eligibility is distance-based with on-screen priority and hard caps (`MODEL_MAX`, `MODEL_MAX_ALL`) to avoid draw-call explosions. Each model owns its own `modelMatrix`; shared scratch matrices are forbidden because they caused stacking/flicker.
|
- Fleet model eligibility is distance-based with on-screen priority and hard caps (`MODEL_MAX`, `MODEL_MAX_ALL`) to avoid draw-call explosions. Each model owns its own `modelMatrix`; shared scratch matrices are forbidden because they caused stacking/flicker.
|
||||||
- Tracked aircraft use standalone model primitives driven from the already-settled dead-reckoned display position, while the tracked Cesium entity remains billboard-backed so `viewer.trackedEntity` always has a ready bounding sphere.
|
- Tracked aircraft use standalone model primitives driven from the already-settled dead-reckoned display position, while the tracked Cesium entity remains billboard-backed so `viewer.trackedEntity` always has a ready bounding sphere.
|
||||||
- Flights and military layers mirror the same tracking invariants: no warm-up freeze/jump, altitude-scaled framing, trail head glued to the displayed plane, no pull-out when switching targets, and no cross-layer orphan when switching between commercial and military tracks.
|
- Flights and military layers mirror the same tracking invariants: no warm-up freeze/jump, altitude-scaled framing, trail head glued to the displayed plane, no pull-out when switching targets, and no cross-layer orphan when switching between commercial and military tracks.
|
||||||
- Regression surface: `npm run test:track` drives the real app headless with synthetic aircraft feeds and asserts the tracking invariants without depending on live OpenSky/adsb data. `src/data/trackedModelRegime.test.mjs` pins the tracked contact's threshold math, the enter/exit asymmetry, and the default-on / cockpit / TR-3B / deselect wiring in both layers.
|
- Regression surface: `npm run test:track` drives the real app headless with synthetic aircraft feeds and asserts the tracking invariants without depending on live OpenSky/adsb data. Arrival-rotation checks observe the first reveal within a bounded wall-clock window before the one-second idle fallback, rather than assuming the 80 ms fleet cadence fits inside four rendered frames; an incorrect first orientation fails immediately. `src/data/trackedModelRegime.test.mjs` pins the tracked contact's threshold math, the enter/exit asymmetry, and the default-on / cockpit / TR-3B / deselect wiring in both layers.
|
||||||
|
|
||||||
### TR-3B conversion Easter egg (August 2026)
|
### TR-3B conversion Easter egg (August 2026)
|
||||||
|
|
||||||
|
|||||||
@@ -1067,6 +1067,7 @@
|
|||||||
"scripts/assemble-changelog.mjs",
|
"scripts/assemble-changelog.mjs",
|
||||||
"src/tooling/changelogFragments.test.mjs",
|
"src/tooling/changelogFragments.test.mjs",
|
||||||
"src/data/gbfsProxyRateLimit.test.mjs",
|
"src/data/gbfsProxyRateLimit.test.mjs",
|
||||||
"src/data/routeSegment.test.mjs",
|
"src/terrainHeightsBounds.test.mjs",
|
||||||
"src/terrainHeightsBounds.test.mjs"
|
"server/providers/overpass/requestAdmission.test.mjs",
|
||||||
|
"src/tooling/trackArrivalObservation.test.mjs"
|
||||||
]
|
]
|
||||||
@@ -252,6 +252,7 @@
|
|||||||
"server/providers/common/http.js",
|
"server/providers/common/http.js",
|
||||||
"server/providers/common/rate-limit.js",
|
"server/providers/common/rate-limit.js",
|
||||||
"server/providers/common/request.js",
|
"server/providers/common/request.js",
|
||||||
|
"server/providers/common/same-site.js",
|
||||||
"server/providers/overpass.js",
|
"server/providers/overpass.js",
|
||||||
"server/providers/overpass/cache.js",
|
"server/providers/overpass/cache.js",
|
||||||
"server/providers/overpass/constants.js",
|
"server/providers/overpass/constants.js",
|
||||||
@@ -261,6 +262,7 @@
|
|||||||
"server/providers/places/routes.js",
|
"server/providers/places/routes.js",
|
||||||
"src/data/placeProviderPayloads.js",
|
"src/data/placeProviderPayloads.js",
|
||||||
"src/data/routeSteps.js",
|
"src/data/routeSteps.js",
|
||||||
|
"src/localRequestGate.mjs",
|
||||||
"src/sources/httpBody.js",
|
"src/sources/httpBody.js",
|
||||||
"src/i18n/marker.js",
|
"src/i18n/marker.js",
|
||||||
"src/sources/overpass.js",
|
"src/sources/overpass.js",
|
||||||
|
|||||||
@@ -306,7 +306,16 @@ try {
|
|||||||
await observeNetwork(page);
|
await observeNetwork(page);
|
||||||
page.on('pageerror', (error) => result.errors.push(error.message));
|
page.on('pageerror', (error) => result.errors.push(error.message));
|
||||||
page.on('console', (message) => {
|
page.on('console', (message) => {
|
||||||
if (message.type() === 'error' && result.consoleErrors.length < 30)
|
if (message.type() !== 'error') return;
|
||||||
|
// A keyless server does not offer optional Google tokens. Keep every other
|
||||||
|
// HTTP or JavaScript error visible, including failures of this route other
|
||||||
|
// than its expected capability-miss 404.
|
||||||
|
const optionalTokenMiss =
|
||||||
|
message.location().url === new URL('/api/google/tiles-token', url).href &&
|
||||||
|
/^Failed to load resource: the server responded with a status of 404\b/.test(
|
||||||
|
message.text(),
|
||||||
|
);
|
||||||
|
if (!optionalTokenMiss && result.consoleErrors.length < 30)
|
||||||
result.consoleErrors.push(message.text());
|
result.consoleErrors.push(message.text());
|
||||||
});
|
});
|
||||||
page.on('requestfailed', (request) => {
|
page.on('requestfailed', (request) => {
|
||||||
|
|||||||
@@ -94,6 +94,7 @@
|
|||||||
|
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import puppeteer from 'puppeteer';
|
import puppeteer from 'puppeteer';
|
||||||
|
import { arrivalRotationPassed } from '../src/tooling/trackArrivalObservation.mjs';
|
||||||
import { classifyAircraft, CLASS_SCALE_3D, CLASS_MODEL_REAL } from '../src/data/aircraftClass.js';
|
import { classifyAircraft, CLASS_SCALE_3D, CLASS_MODEL_REAL } from '../src/data/aircraftClass.js';
|
||||||
import { ensureGeoidReady, geoidHeight } from '../src/data/geoid.js';
|
import { ensureGeoidReady, geoidHeight } from '../src/data/geoid.js';
|
||||||
|
|
||||||
@@ -2695,16 +2696,17 @@ async function main() {
|
|||||||
// reappear wearing its stale/creation-north nose for up to 1 s).
|
// reappear wearing its stale/creation-north nose for up to 1 s).
|
||||||
// Staged deterministically: park the camera nadir over the synthetic
|
// Staged deterministically: park the camera nadir over the synthetic
|
||||||
// cluster, let rotations settle, TAMPER a billboard's rotation, then
|
// cluster, let rotations settle, TAMPER a billboard's rotation, then
|
||||||
// trigger each path with the camera idle. The idle-drift catch-up
|
// trigger each path with the camera idle. The fleet runs every 80 ms,
|
||||||
// (ROTATION_REFRESH_MS = 1 s) can coincidentally mask a regression in a
|
// independently of rendered FPS. Observe the first completed settle and
|
||||||
// ~300 ms SwiftShader frame window, so a regression shows as an
|
// reveal frames within 500 ms of forcing moveEnd, before the 1 s idle
|
||||||
// intermittent (not guaranteed) failure — never a false failure.
|
// rotation refresh can repair an incorrect first reveal.
|
||||||
// ============================================================
|
// ============================================================
|
||||||
console.log('\nChange 4 — arrival rotation: moveEnd settle pass + horizon-reveal pass');
|
console.log('\nChange 4 — arrival rotation: moveEnd settle pass + horizon-reveal pass');
|
||||||
const arrival = await evalPage(async () => {
|
const arrival = await evalPage(async () => {
|
||||||
const v = window.__godsEyeView.viewer;
|
const v = window.__godsEyeView.viewer;
|
||||||
const dm = window.__godsEyeView.dataManager;
|
const dm = window.__godsEyeView.dataManager;
|
||||||
const { screenProjectedRotation } = await import(`${window.__gevQaSourceBase || '/src'}/data/iconOrientation.js`);
|
const { screenProjectedRotation } = await import(`${window.__gevQaSourceBase || '/src'}/data/iconOrientation.js`);
|
||||||
|
const { observeArrivalRotation } = await import(`${window.__gevQaSourceBase || '/src'}/tooling/trackArrivalObservation.mjs`);
|
||||||
// 3D models OFF for this phase: a model-handed-off billboard is hidden
|
// 3D models OFF for this phase: a model-handed-off billboard is hidden
|
||||||
// and skips rotation updates entirely — the probes need live billboards
|
// and skips rotation updates entirely — the probes need live billboards
|
||||||
// (this is also the app's default state the field report came from).
|
// (this is also the app's default state the field report came from).
|
||||||
@@ -2766,39 +2768,29 @@ async function main() {
|
|||||||
const bb = findBB(id);
|
const bb = findBB(id);
|
||||||
if (!bb || !bb.show) return { error: `${id} missing/hidden` };
|
if (!bb || !bb.show) return { error: `${id} missing/hidden` };
|
||||||
const r0 = bb.rotation; // settled tamper origin; correctness uses a fresh projection below
|
const r0 = bb.rotation; // settled tamper origin; correctness uses a fresh projection below
|
||||||
// (a) settle pass: tamper, then raise moveEnd with the camera IDLE —
|
const observed = await observeArrivalRotation({
|
||||||
// the pose signature is unchanged, so only the moveEnd hook can fix
|
scene: v.scene,
|
||||||
// this before the 1 s catch-up.
|
startMoveEnd() {
|
||||||
bb.rotation = r0 + 1.5;
|
// Only moveEnd can repair the tampered angle at the idle pose.
|
||||||
v.camera.moveEnd.raiseEvent();
|
bb.rotation = r0 + 1.5;
|
||||||
await nextFrames(2);
|
v.camera.moveEnd.raiseEvent();
|
||||||
const afterMoveEnd = bb.rotation;
|
},
|
||||||
// A real-GPU fleet tick can land near the edge of the frame budget and
|
startReveal() {
|
||||||
// advance the contact before the probe reads it. Compare with the
|
// Hide immediately after the confirmed settle frame. The next
|
||||||
// production projection at the CURRENT position, not the now-stale r0.
|
// eligible fleet tick must reveal AND re-aim in that same tick.
|
||||||
const expectedMoveEnd = screenProjectedRotation(v.scene, bb.position, course, null);
|
bb.rotation = r0 + 1.5;
|
||||||
// (b) reveal pass: tamper + hide — the next fleet tick must flip it
|
bb.show = false;
|
||||||
// visible AND correct the nose in that same tick (camera still idle,
|
},
|
||||||
// no moveEnd raised). Diagnostic fields (round 5): record WHICH frame
|
read() {
|
||||||
// the flip landed on (flipFrame; -1 = never within the extended
|
return {
|
||||||
// window) and whether a 3D model owns this id — distinguishes "tick
|
shown: bb.show,
|
||||||
// slow under load" from "occluder says hidden" from "model handoff".
|
rotationError: angDiff(bb.rotation,
|
||||||
bb.rotation = r0 + 1.5;
|
screenProjectedRotation(v.scene, bb.position, course, null)),
|
||||||
bb.show = false;
|
};
|
||||||
let flipFrame = -1;
|
},
|
||||||
for (let f = 1; f <= 6; f++) {
|
});
|
||||||
await nextFrames(1);
|
|
||||||
if (bb.show && flipFrame === -1) flipFrame = f;
|
|
||||||
if (f >= 2 && flipFrame !== -1) break;
|
|
||||||
}
|
|
||||||
const expectedReveal = screenProjectedRotation(v.scene, bb.position, course, null);
|
|
||||||
const hasModel = !!(window.__g3dFindModel && window.__g3dFindModel(id));
|
const hasModel = !!(window.__g3dFindModel && window.__g3dFindModel(id));
|
||||||
return {
|
return { r0, ...observed, hasModel };
|
||||||
r0,
|
|
||||||
dMoveEnd: angDiff(afterMoveEnd, expectedMoveEnd),
|
|
||||||
dReveal: angDiff(bb.rotation, expectedReveal),
|
|
||||||
shown: bb.show, flipFrame, hasModel,
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
const flightCourse = window.__SYNTH.flights.find((f) => f.icao === 'aaa002')?.track ?? 0;
|
const flightCourse = window.__SYNTH.flights.find((f) => f.icao === 'aaa002')?.track ?? 0;
|
||||||
const militaryCourse = window.__SYNTH.military.find((m) => m.hex === 'bbb101')?.track ?? 0;
|
const militaryCourse = window.__SYNTH.military.find((m) => m.hex === 'bbb101')?.track ?? 0;
|
||||||
@@ -2807,22 +2799,13 @@ async function main() {
|
|||||||
military: await probe('bbb101', militaryCourse),
|
military: await probe('bbb101', militaryCourse),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? a.error
|
const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? `${a.error}; elapsed=${Number(a.elapsedMs).toFixed(1)} ms`
|
||||||
: `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° shown=${a.shown} flipFrame=${a.flipFrame} hasModel=${a.hasModel}`);
|
: `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° shown=${a.shown} flipFrame=${a.flipFrame} settle=${a.moveEndMs.toFixed(1)} ms reveal=${a.revealMs.toFixed(1)} ms total=${a.elapsedMs.toFixed(1)} ms hasModel=${a.hasModel}`);
|
||||||
const TAMPER_DEG = 1.5 * 180 / Math.PI; // ≈ 86° — an uncorrected nose stays this far off
|
const TAMPER_DEG = 1.5 * 180 / Math.PI; // ≈ 86° — an uncorrected nose stays this far off
|
||||||
// Reveal budget re-pinned 2 → 4 frames (round 5): the diagnostic probe
|
record(`arrival: moveEnd + first reveal both re-aim before idle refresh, flights (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
|
||||||
// shows the military flip consistently lands on frame 2 — the exact edge
|
arrivalRotationPassed(arrival.flights), fmtArr(arrival.flights));
|
||||||
// of the old budget — so any load hiccup flaked it (flights lands frame
|
record(`arrival: moveEnd + first reveal both re-aim before idle refresh, military (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
|
||||||
// 1). The regression this protects against is a reveal that waits for
|
arrivalRotationPassed(arrival.military), fmtArr(arrival.military));
|
||||||
// the 1 s idle catch-up (~30-60 frames) or never re-aims; 4 frames still
|
|
||||||
// catches that with a wide margin, with zero-headroom flakes gone.
|
|
||||||
const arrOk = (a) => !!a && !a.error && a.shown
|
|
||||||
&& (a.flipFrame === -1 ? false : a.flipFrame <= 4)
|
|
||||||
&& a.dMoveEnd * 180 / Math.PI < 10 && a.dReveal * 180 / Math.PI < 10;
|
|
||||||
record(`arrival: moveEnd + reveal both re-aim within 4 frames, flights (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
|
|
||||||
arrOk(arrival.flights), fmtArr(arrival.flights));
|
|
||||||
record(`arrival: moveEnd + reveal both re-aim within 4 frames, military (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
|
|
||||||
arrOk(arrival.military), fmtArr(arrival.military));
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// DISPLAY FLOOR (2026-08-19): a grounded contact must never render below
|
// DISPLAY FLOOR (2026-08-19): a grounded contact must never render below
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
|
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
|
||||||
import { readRequestBodyCapped } from './common/request.js';
|
import { readRequestBodyCapped } from './common/request.js';
|
||||||
|
import { admitSameSite } from './common/same-site.js';
|
||||||
import {
|
import {
|
||||||
OVERPASS_MAX_BODY_BYTES,
|
OVERPASS_MAX_BODY_BYTES,
|
||||||
OVERPASS_MAX_CONCURRENT,
|
OVERPASS_MAX_CONCURRENT,
|
||||||
@@ -27,6 +28,14 @@ const _overpassInFlight = new Map();
|
|||||||
|
|
||||||
let _overpassConcurrent = 0;
|
let _overpassConcurrent = 0;
|
||||||
|
|
||||||
|
// Bound body-reading and validation work, including malformed requests and
|
||||||
|
// cache hits. Keep the tighter upstream budget below independent of this gate.
|
||||||
|
const _overpassRequestLimiter = makeRateLimiter({
|
||||||
|
windowMs: 60_000,
|
||||||
|
max: 300,
|
||||||
|
globalMax: 900,
|
||||||
|
});
|
||||||
|
|
||||||
const _overpassRateLimiter = makeRateLimiter({
|
const _overpassRateLimiter = makeRateLimiter({
|
||||||
windowMs: 60_000,
|
windowMs: 60_000,
|
||||||
max: 90,
|
max: 90,
|
||||||
@@ -94,6 +103,17 @@ function overpassProxy({ routing = {} } = {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (admitSameSite(req, res)) return;
|
||||||
|
if (!_overpassRequestLimiter(clientKey(req))) {
|
||||||
|
res.writeHead(429, {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
'Retry-After': '60',
|
||||||
|
});
|
||||||
|
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Collect POST body with a hard byte cap (Overpass QL queries are small)
|
// Collect POST body with a hard byte cap (Overpass QL queries are small)
|
||||||
let body;
|
let body;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -18,6 +18,29 @@ function isOverpassBoundaryQuery(cacheKey) {
|
|||||||
return /is_in\s*\(|\bpivot\b/i.test(String(cacheKey || ''));
|
return /is_in\s*\(|\bpivot\b/i.test(String(cacheKey || ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Input has already had quoted literals and comments removed. Consume each
|
||||||
|
// character once: a regex that searches for a closing bracket from every `[` is
|
||||||
|
// quadratic on an unterminated filter. Nested or unmatched brackets are invalid.
|
||||||
|
function stripTagFilters(text) {
|
||||||
|
const parts = [];
|
||||||
|
let start = 0;
|
||||||
|
let inFilter = false;
|
||||||
|
for (let i = 0; i < text.length; i += 1) {
|
||||||
|
if (text[i] === '[') {
|
||||||
|
if (inFilter) return null;
|
||||||
|
parts.push(text.slice(start, i), ' ');
|
||||||
|
inFilter = true;
|
||||||
|
} else if (text[i] === ']') {
|
||||||
|
if (!inFilter) return null;
|
||||||
|
inFilter = false;
|
||||||
|
start = i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (inFilter) return null;
|
||||||
|
parts.push(text.slice(start));
|
||||||
|
return parts.join('');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate + clamp an Overpass form body. Defends the generic proxy against
|
* Validate + clamp an Overpass form body. Defends the generic proxy against
|
||||||
* planet-scale abuse: requires exactly one `data` query in which EVERY element
|
* planet-scale abuse: requires exactly one `data` query in which EVERY element
|
||||||
@@ -50,6 +73,10 @@ function sanitizeOverpassBody(rawBody) {
|
|||||||
// Blank quoted literals + strip comments in one lexer pass so a fake bound or a
|
// Blank quoted literals + strip comments in one lexer pass so a fake bound or a
|
||||||
// `//` inside a string can't hide an unbounded selector (or satisfy a bound).
|
// `//` inside a string can't hide an unbounded selector (or satisfy a bound).
|
||||||
const stripped = stripOverpassNoise(data);
|
const stripped = stripOverpassNoise(data);
|
||||||
|
const withoutTags = stripTagFilters(stripped);
|
||||||
|
if (withoutTags === null) {
|
||||||
|
return { ok: false, error: 'Malformed Overpass tag filter' };
|
||||||
|
}
|
||||||
|
|
||||||
// Reject oversized radii in EVERY around form — point `around:r,lat,lon` AND the
|
// Reject oversized radii in EVERY around form — point `around:r,lat,lon` AND the
|
||||||
// input-set form `around.set:r` — and parse the full numeric token so scientific
|
// input-set form `around.set:r` — and parse the full numeric token so scientific
|
||||||
@@ -93,20 +120,19 @@ function sanitizeOverpassBody(rawBody) {
|
|||||||
// statement. So `way[...]->.a` (global assigned to a set) is rejected, while the
|
// statement. So `way[...]->.a` (global assigned to a set) is rejected, while the
|
||||||
// app's `is_in(...)->.a; area.a[...]` and `area(id)->.x; rel(pivot.x)` validate.
|
// app's `is_in(...)->.a; area.a[...]` and `area(id)->.x; rel(pivot.x)` validate.
|
||||||
const boundedSets = new Set();
|
const boundedSets = new Set();
|
||||||
for (let stmt of stripped.split(';')) {
|
for (let stmt of withoutTags.split(';')) {
|
||||||
stmt = stmt.trim();
|
stmt = stmt.trim();
|
||||||
if (!stmt || stmt.startsWith('[') || /^out\b/.test(stmt)) continue;
|
if (!stmt || stmt.startsWith('[') || /^out\b/.test(stmt)) continue;
|
||||||
|
|
||||||
// Strip output-set assignments (NOT input bounds), then strip bracket tag
|
// Strip output-set assignments (NOT input bounds). Bracket tag filters were
|
||||||
// filters so a tag KEY/value (e.g. `way[is_in]`, `node[around]`) can never be
|
// removed above so a tag KEY/value (e.g. `way[is_in]`, `node[around]`) cannot be
|
||||||
// misread as a spatial bound. Bounds live in (...) / function calls / set
|
// misread as a spatial bound. Bounds live in (...) / function calls / set
|
||||||
// refs, never inside [...], so the probe loses nothing real.
|
// refs, never inside [...], so the probe loses nothing real.
|
||||||
const outSets = [];
|
const outSets = [];
|
||||||
const body = stmt.replace(/->\s*\.(\w+)/g, (_, name) => {
|
const probe = stmt.replace(/->\s*\.(\w+)/g, (_, name) => {
|
||||||
outSets.push(name);
|
outSets.push(name);
|
||||||
return ' ';
|
return ' ';
|
||||||
});
|
});
|
||||||
const probe = body.replace(/\[[^\]]*\]/g, ' ');
|
|
||||||
|
|
||||||
// Reject element-in-area scans on the TAG-STRIPPED probe, so a tag filter
|
// Reject element-in-area scans on the TAG-STRIPPED probe, so a tag filter
|
||||||
// between the selector and the area filter (way["highway"](area.a)) can't hide
|
// between the selector and the area filter (way["highway"](area.a)) can't hide
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { Readable } from 'node:stream';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { overpassProxy } from '../overpass.js';
|
||||||
|
import { sanitizeOverpassBody } from './query.js';
|
||||||
|
|
||||||
|
const form = (query) => `data=${encodeURIComponent(query)}`;
|
||||||
|
|
||||||
|
test('bracket validation rejects malformed filters without trusting their contents as bounds', () => {
|
||||||
|
for (const query of [
|
||||||
|
'node[',
|
||||||
|
'node]',
|
||||||
|
'node[[name]](around:20,1,1);out;',
|
||||||
|
'node[name=around:20];out;',
|
||||||
|
'node[is_in];out;',
|
||||||
|
'node[name="(around:20,1,1)"];out;',
|
||||||
|
'node(around:20,1,1);way[name];out;',
|
||||||
|
'way[highway](area.a);out;',
|
||||||
|
'node[->.a](around:20,1,1);way.a;out;',
|
||||||
|
]) {
|
||||||
|
assert.equal(sanitizeOverpassBody(form(query)).ok, false, query);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bounded queries preserve quoted brackets, comments, multiple tags and timeout clamping', () => {
|
||||||
|
for (const query of [
|
||||||
|
'[out:json][timeout:999];node[name="[Town]; ]"](around:20,1,1);out;',
|
||||||
|
'/* [ */way[highway][name](30.1,-97.9,30.5,-97.5);out geom;',
|
||||||
|
'is_in(30.2,-97.7)->.a;area.a[boundary="administrative"];out tags;',
|
||||||
|
'area(3600039152)->.x;rel(pivot.x);out geom;',
|
||||||
|
]) {
|
||||||
|
const result = sanitizeOverpassBody(form(query));
|
||||||
|
assert.equal(result.ok, true, query);
|
||||||
|
assert.equal(
|
||||||
|
new URLSearchParams(result.body).get('data'),
|
||||||
|
query.replace('[timeout:999]', '[timeout:30]'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a burst of maximum-size unterminated filters completes within a bounded process deadline', () => {
|
||||||
|
// A child deadline can stop synchronous regex backtracking; a test timeout in
|
||||||
|
// the same event loop cannot. The old parser takes several seconds for this.
|
||||||
|
const result = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
'--input-type=module',
|
||||||
|
'-e',
|
||||||
|
`import { sanitizeOverpassBody } from ${JSON.stringify(new URL('./query.js', import.meta.url).href)};
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
if (sanitizeOverpassBody('data=node' + '['.repeat(24000)).ok) process.exit(1);
|
||||||
|
}`,
|
||||||
|
],
|
||||||
|
{ timeout: 2000, encoding: 'utf8' },
|
||||||
|
);
|
||||||
|
assert.ifError(result.error);
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
});
|
||||||
|
|
||||||
|
function handler(hook = 'configureServer') {
|
||||||
|
let route;
|
||||||
|
overpassProxy()[hook]({
|
||||||
|
middlewares: {
|
||||||
|
use(path, fn) {
|
||||||
|
if (path === '/api/overpass') route = fn;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return route;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function call(
|
||||||
|
route,
|
||||||
|
{
|
||||||
|
headers = {},
|
||||||
|
peer = '127.0.0.1',
|
||||||
|
body = 'data=node[',
|
||||||
|
forbidRead = false,
|
||||||
|
method = 'POST',
|
||||||
|
url = '/',
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
let reads = 0;
|
||||||
|
const req = new Readable({
|
||||||
|
read() {
|
||||||
|
reads++;
|
||||||
|
assert.equal(forbidRead, false, 'refused request must not read its body');
|
||||||
|
this.push(Buffer.from(body));
|
||||||
|
this.push(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.assign(req, {
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
headers: { host: 'localhost:4173', ...headers },
|
||||||
|
socket: { remoteAddress: peer },
|
||||||
|
});
|
||||||
|
const res = {
|
||||||
|
statusCode: 200,
|
||||||
|
headers: {},
|
||||||
|
setHeader(key, value) {
|
||||||
|
this.headers[key.toLowerCase()] = value;
|
||||||
|
},
|
||||||
|
writeHead(status, values) {
|
||||||
|
this.statusCode = status;
|
||||||
|
for (const [key, value] of Object.entries(values))
|
||||||
|
this.setHeader(key, value);
|
||||||
|
},
|
||||||
|
end(value) {
|
||||||
|
this.body = value;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await route(req, res);
|
||||||
|
return { ...res, reads };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const hook of ['configureServer', 'configurePreviewServer']) {
|
||||||
|
test(`${hook}: cross-site requests are rejected before consuming a body or fetching`, async (t) => {
|
||||||
|
t.mock.method(globalThis, 'fetch', () =>
|
||||||
|
assert.fail('unexpected upstream fetch'),
|
||||||
|
);
|
||||||
|
const route = handler(hook);
|
||||||
|
for (const headers of [
|
||||||
|
{ origin: 'https://foreign.example', 'content-type': 'text/plain' },
|
||||||
|
{ 'sec-fetch-site': 'cross-site' },
|
||||||
|
{ 'sec-fetch-site': 'same-site' },
|
||||||
|
{ origin: 'null' },
|
||||||
|
{ 'x-forwarded-for': '127.0.0.1' },
|
||||||
|
]) {
|
||||||
|
const response = await call(route, { headers, forbidRead: true });
|
||||||
|
assert.equal(response.statusCode, 403);
|
||||||
|
assert.equal(response.reads, 0);
|
||||||
|
assert.equal(response.headers['cache-control'], 'no-store');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('same-origin, LAN and non-browser requests still reach validation; GET status stays available', async () => {
|
||||||
|
const route = handler();
|
||||||
|
for (const headers of [
|
||||||
|
{},
|
||||||
|
{ origin: 'http://localhost:4173', 'sec-fetch-site': 'same-origin' },
|
||||||
|
{ host: '192.168.1.10:4173', origin: 'http://192.168.1.10:4173' },
|
||||||
|
]) {
|
||||||
|
const response = await call(route, { headers });
|
||||||
|
assert.equal(response.statusCode, 400);
|
||||||
|
assert.equal(response.reads, 1);
|
||||||
|
assert.match(response.body, /Malformed Overpass tag filter/);
|
||||||
|
}
|
||||||
|
const response = await call(route, {
|
||||||
|
method: 'GET',
|
||||||
|
url: '/status',
|
||||||
|
forbidRead: true,
|
||||||
|
});
|
||||||
|
assert.equal(response.statusCode, 200);
|
||||||
|
assert.equal(typeof JSON.parse(response.body).configured, 'boolean');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid requests spend admission quota before parsing, including on an unconfigured server', async (t) => {
|
||||||
|
let now = Date.now() + 120000;
|
||||||
|
t.mock.method(Date, 'now', () => now);
|
||||||
|
t.mock.method(globalThis, 'fetch', () =>
|
||||||
|
assert.fail('unexpected upstream fetch'),
|
||||||
|
);
|
||||||
|
const route = handler();
|
||||||
|
const peer = 'admission-limit-test';
|
||||||
|
for (let i = 0; i < 300; i++) {
|
||||||
|
assert.equal((await call(route, { peer })).statusCode, 400);
|
||||||
|
}
|
||||||
|
const denied = await call(route, { peer, forbidRead: true });
|
||||||
|
assert.equal(denied.statusCode, 429);
|
||||||
|
assert.equal(denied.reads, 0);
|
||||||
|
assert.equal(denied.headers['retry-after'], '60');
|
||||||
|
assert.equal((await call(route, { peer: 'other-peer' })).statusCode, 400);
|
||||||
|
now += 60001;
|
||||||
|
assert.equal((await call(route, { peer })).statusCode, 400);
|
||||||
|
});
|
||||||
@@ -38,7 +38,10 @@ export function createGoogleTokenSource({
|
|||||||
headers: { Accept: 'application/json' },
|
headers: { Accept: 'application/json' },
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
});
|
});
|
||||||
if (!response.ok) return null;
|
if (!response.ok) {
|
||||||
|
await response.body?.cancel();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return parseToken(await response.json(), now());
|
return parseToken(await response.json(), now());
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -50,6 +50,23 @@ test('requests refused for the same token renew it once', async () => {
|
|||||||
assert.equal(calls.length, 2);
|
assert.equal(calls.length, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a refused token response releases its unread body before returning', async () => {
|
||||||
|
for (const status of [404, 429, 500]) {
|
||||||
|
let cancelled = false;
|
||||||
|
const response = new Response(
|
||||||
|
new ReadableStream({
|
||||||
|
cancel() {
|
||||||
|
cancelled = true;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ status },
|
||||||
|
);
|
||||||
|
const tokens = createGoogleTokenSource({ fetchImpl: async () => response });
|
||||||
|
assert.equal(await tokens.token(), null);
|
||||||
|
assert.equal(cancelled, true, `HTTP ${status} body released`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('a server that does not offer tokens yields none', async () => {
|
test('a server that does not offer tokens yields none', async () => {
|
||||||
for (const answer of [
|
for (const answer of [
|
||||||
new Response('Not found', { status: 404 }),
|
new Response('Not found', { status: 404 }),
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/** A reveal must beat the one-second idle rotation refresh, regardless of FPS. */
|
||||||
|
export const ARRIVAL_OBSERVATION_MS = 500;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observe the first completed moveEnd frame and the first subsequent reveal.
|
||||||
|
* The deadline starts when moveEnd is forced, before its rotation baseline, so
|
||||||
|
* waiting for either frame cannot let the one-second idle refresh mask a bug.
|
||||||
|
* `read` snapshots visibility and rotation error against the current position.
|
||||||
|
*/
|
||||||
|
export function observeArrivalRotation({
|
||||||
|
scene,
|
||||||
|
startMoveEnd,
|
||||||
|
startReveal,
|
||||||
|
read,
|
||||||
|
now = () => performance.now(),
|
||||||
|
setTimer = setTimeout,
|
||||||
|
clearTimer = clearTimeout,
|
||||||
|
}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const startedAt = now();
|
||||||
|
let moveEnd = null;
|
||||||
|
let moveEndMs = null;
|
||||||
|
let revealFrames = 0;
|
||||||
|
let done = false;
|
||||||
|
let remove;
|
||||||
|
let timer;
|
||||||
|
const cleanup = () => {
|
||||||
|
done = true;
|
||||||
|
remove?.();
|
||||||
|
clearTimer(timer);
|
||||||
|
};
|
||||||
|
const finish = (result) => {
|
||||||
|
if (done) return;
|
||||||
|
cleanup();
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
const expired = () => ({
|
||||||
|
error: `arrival ${moveEnd ? 'reveal' : 'moveEnd'} observation exceeded ${ARRIVAL_OBSERVATION_MS} ms`,
|
||||||
|
elapsedMs: now() - startedAt,
|
||||||
|
moveEndMs,
|
||||||
|
flipFrame: -1,
|
||||||
|
});
|
||||||
|
remove = scene.postRender.addEventListener(() => {
|
||||||
|
if (done) return;
|
||||||
|
try {
|
||||||
|
const elapsedMs = now() - startedAt;
|
||||||
|
if (elapsedMs >= ARRIVAL_OBSERVATION_MS) {
|
||||||
|
finish(expired());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sample = read();
|
||||||
|
if (moveEnd === null) {
|
||||||
|
moveEnd = sample;
|
||||||
|
moveEndMs = elapsedMs;
|
||||||
|
startReveal();
|
||||||
|
} else {
|
||||||
|
revealFrames++;
|
||||||
|
if (sample.shown) {
|
||||||
|
// Keep the FIRST visible result, including a wrong angle. Never
|
||||||
|
// wait for a subsequent tick to repair a failed reveal.
|
||||||
|
finish({
|
||||||
|
dMoveEnd: moveEnd.rotationError,
|
||||||
|
dReveal: sample.rotationError,
|
||||||
|
shown: sample.shown,
|
||||||
|
flipFrame: revealFrames,
|
||||||
|
moveEndMs,
|
||||||
|
revealMs: elapsedMs - moveEndMs,
|
||||||
|
elapsedMs,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scene.requestRender();
|
||||||
|
} catch (error) {
|
||||||
|
cleanup();
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
timer = setTimer(() => finish(expired()), ARRIVAL_OBSERVATION_MS);
|
||||||
|
try {
|
||||||
|
startMoveEnd();
|
||||||
|
scene.requestRender();
|
||||||
|
} catch (error) {
|
||||||
|
cleanup();
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test the captured frames without retrying a bad first observation. */
|
||||||
|
export function arrivalRotationPassed(observation) {
|
||||||
|
return (
|
||||||
|
!!observation &&
|
||||||
|
!observation.error &&
|
||||||
|
observation.shown === true &&
|
||||||
|
observation.elapsedMs < ARRIVAL_OBSERVATION_MS &&
|
||||||
|
(observation.dMoveEnd * 180) / Math.PI < 10 &&
|
||||||
|
(observation.dReveal * 180) / Math.PI < 10
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
ARRIVAL_OBSERVATION_MS,
|
||||||
|
arrivalRotationPassed,
|
||||||
|
observeArrivalRotation,
|
||||||
|
} from './trackArrivalObservation.mjs';
|
||||||
|
|
||||||
|
function fixture() {
|
||||||
|
let time = 0;
|
||||||
|
let state = { shown: true, rotationError: 0 };
|
||||||
|
const listeners = new Set();
|
||||||
|
const timers = new Map();
|
||||||
|
let renders = 0;
|
||||||
|
let moveEnds = 0;
|
||||||
|
const observation = observeArrivalRotation({
|
||||||
|
scene: {
|
||||||
|
postRender: {
|
||||||
|
addEventListener(listener) {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
requestRender() {
|
||||||
|
renders++;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
startMoveEnd() {
|
||||||
|
moveEnds++;
|
||||||
|
state.rotationError = 1.5;
|
||||||
|
},
|
||||||
|
startReveal() {
|
||||||
|
state = { shown: false, rotationError: 1.5 };
|
||||||
|
},
|
||||||
|
read: () => ({ ...state }),
|
||||||
|
now: () => time,
|
||||||
|
setTimer(callback, delay) {
|
||||||
|
const handle = {};
|
||||||
|
timers.set(handle, { callback, at: time + delay });
|
||||||
|
return handle;
|
||||||
|
},
|
||||||
|
clearTimer(handle) {
|
||||||
|
timers.delete(handle);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
observation,
|
||||||
|
frame(at, nextState) {
|
||||||
|
time = at;
|
||||||
|
state = { ...state, ...nextState };
|
||||||
|
for (const listener of [...listeners]) listener();
|
||||||
|
},
|
||||||
|
expire(at) {
|
||||||
|
time = at;
|
||||||
|
for (const timer of [...timers.values()])
|
||||||
|
if (timer.at <= at) timer.callback();
|
||||||
|
},
|
||||||
|
get resources() {
|
||||||
|
return {
|
||||||
|
listeners: listeners.size,
|
||||||
|
timers: timers.size,
|
||||||
|
renders,
|
||||||
|
moveEnds,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('80 ms fleet scheduling passes after more than four fast rendered frames', async () => {
|
||||||
|
const f = fixture();
|
||||||
|
f.frame(8, { shown: true, rotationError: 0 });
|
||||||
|
// The forced fleet tick was at 8 ms. A 125 FPS renderer can complete nine
|
||||||
|
// more frames before the next 80 ms fleet interval becomes eligible.
|
||||||
|
for (let at = 16; at < 88; at += 8) f.frame(at);
|
||||||
|
f.frame(88, { shown: true, rotationError: 0 });
|
||||||
|
const result = await f.observation;
|
||||||
|
assert.equal(result.flipFrame, 10);
|
||||||
|
assert.equal(result.moveEndMs, 8);
|
||||||
|
assert.equal(result.revealMs, 80);
|
||||||
|
assert.equal(result.elapsedMs, 88);
|
||||||
|
assert.equal(arrivalRotationPassed(result), true);
|
||||||
|
assert.equal(f.resources.listeners, 0);
|
||||||
|
assert.equal(f.resources.timers, 0);
|
||||||
|
assert.equal(f.resources.moveEnds, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a wrong first visible angle fails even if the next frame corrects it', async () => {
|
||||||
|
const f = fixture();
|
||||||
|
f.frame(8, { shown: true, rotationError: 0 });
|
||||||
|
f.frame(88, { shown: true, rotationError: 1.5 });
|
||||||
|
f.frame(96, { shown: true, rotationError: 0 });
|
||||||
|
const result = await f.observation;
|
||||||
|
assert.equal(result.dReveal, 1.5);
|
||||||
|
assert.equal(result.flipFrame, 1);
|
||||||
|
assert.equal(result.elapsedMs, 88);
|
||||||
|
assert.equal(arrivalRotationPassed(result), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a wrong first moveEnd angle is retained rather than repaired by reveal', async () => {
|
||||||
|
const f = fixture();
|
||||||
|
f.frame(8, { shown: true, rotationError: 1.5 });
|
||||||
|
f.frame(88, { shown: true, rotationError: 0 });
|
||||||
|
const result = await f.observation;
|
||||||
|
assert.equal(result.dMoveEnd, 1.5);
|
||||||
|
assert.equal(arrivalRotationPassed(result), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no reveal fails at a deadline before the one-second idle catch-up', async () => {
|
||||||
|
const f = fixture();
|
||||||
|
assert.equal(ARRIVAL_OBSERVATION_MS, 500);
|
||||||
|
f.frame(8, { shown: true, rotationError: 0 });
|
||||||
|
f.frame(480);
|
||||||
|
f.expire(500);
|
||||||
|
const result = await f.observation;
|
||||||
|
assert.match(result.error, /reveal.*500 ms/);
|
||||||
|
assert.equal(arrivalRotationPassed(result), false);
|
||||||
|
assert.equal(f.resources.listeners, 0);
|
||||||
|
assert.equal(f.resources.timers, 0);
|
||||||
|
f.frame(1000, { shown: true, rotationError: 0 });
|
||||||
|
assert.equal(result.flipFrame, -1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a late visible frame fails even if the timeout callback is delayed', async () => {
|
||||||
|
const f = fixture();
|
||||||
|
f.frame(8, { shown: true, rotationError: 0 });
|
||||||
|
f.frame(501, { shown: true, rotationError: 0 });
|
||||||
|
const result = await f.observation;
|
||||||
|
assert.match(result.error, /reveal.*500 ms/);
|
||||||
|
assert.equal(result.elapsedMs, 501);
|
||||||
|
assert.equal(arrivalRotationPassed(result), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the deadline includes moveEnd setup rather than restarting when hidden', async () => {
|
||||||
|
const f = fixture();
|
||||||
|
f.frame(400, { shown: true, rotationError: 0 });
|
||||||
|
f.frame(600, { shown: true, rotationError: 0 });
|
||||||
|
const result = await f.observation;
|
||||||
|
assert.equal(result.moveEndMs, 400);
|
||||||
|
assert.equal(result.elapsedMs, 600);
|
||||||
|
assert.equal(arrivalRotationPassed(result), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing moveEnd frames fail without waiting for a render callback', async () => {
|
||||||
|
const f = fixture();
|
||||||
|
f.expire(500);
|
||||||
|
const result = await f.observation;
|
||||||
|
assert.match(result.error, /moveEnd.*500 ms/);
|
||||||
|
assert.equal(arrivalRotationPassed(result), false);
|
||||||
|
assert.equal(f.resources.listeners, 0);
|
||||||
|
assert.equal(f.resources.timers, 0);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user