diff --git a/CHANGELOG.md b/CHANGELOG.md index 408051954..1d40619c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,14 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [Unreleased] +- CCTV testing uses the normal launcher for keyless startup, credential loading, + localhost binding, and explicit LAN-exposure warnings while retaining its + smaller source-pack limits. +- CelesTrak, Launch Library, terrain-height, and aircraft-enrichment failures + return generic error messages. Related diagnostics omit raw exception details + and upstream error bodies; response statuses and cache fallback remain intact. + Includes the security fixes contributed by Tom-Neverwinter in PR #171. + ### Fixed - Map Source keyboard opening retries focus until the selected tile is visible. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fd75befaf..252bed655 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,21 @@ # God's Eye View Current State +## CCTV launcher and proxy failure responses + +`scripts/dev-cctv.sh` delegates startup to `scripts/dev-fresh.sh`. It retains +its Austin source file, Austin preference, 36-camera Austin limit, and 48-camera +total limit, with environment overrides. Keys are optional; credential loading +and names-only provider provenance follow the normal launcher. The default +binding is localhost. An explicit `HOST=0.0.0.0` uses the same LAN warning as +normal startup. + +The CelesTrak, Launch Library, terrain-height, and ADSBDB middleware return +fixed messages for unexpected failures. Launch Library retains its upstream +HTTP failure status and no-store policy but does not forward the upstream body. +Existing successful, in-flight, missing-data, and stale-cache behavior remains +in place. Failure diagnostics identify the service and, for Launch Library, +HTTP status without printing raw exception text or upstream bodies. + ## Map Source keyboard focus Keyboard opening focuses the selected map-source tile, falling back to the first diff --git a/scripts/dev-cctv.sh b/scripts/dev-cctv.sh index b69d84a14..a1a248203 100755 --- a/scripts/dev-cctv.sh +++ b/scripts/dev-cctv.sh @@ -2,213 +2,12 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -cd "$ROOT_DIR" -PORT="${PORT:-4173}" -HOST="${HOST:-0.0.0.0}" -CCTV_SOURCES_FILE="${CCTV_SOURCES_FILE:-config/cctv_sources.austin.json}" -CCTV_PREFER_AUSTIN="${CCTV_PREFER_AUSTIN:-1}" -CCTV_AUSTIN_MAX_SOURCES="${CCTV_AUSTIN_MAX_SOURCES:-36}" -CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-48}" +# A lighter CCTV testing preset; startup, credentials, and LAN warnings belong +# to the normal launcher. Explicit environment overrides remain supported. +export CCTV_SOURCES_FILE="${CCTV_SOURCES_FILE:-config/cctv_sources.austin.json}" +export CCTV_PREFER_AUSTIN="${CCTV_PREFER_AUSTIN:-1}" +export CCTV_AUSTIN_MAX_SOURCES="${CCTV_AUSTIN_MAX_SOURCES:-36}" +export CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-48}" -if ! command -v npm >/dev/null 2>&1; then - echo "error: npm not found" - exit 1 -fi - -GOOGLE_MAPS_API_KEY_ENV="${GOOGLE_MAPS_API_KEY:-}" -GOOGLE_MAPS_API_KEY_KEYCHAIN="" -GOOGLE_MAPS_API_KEY_SOURCE="" -if command -v security >/dev/null 2>&1; then - for acct in "api-key" "default" "key"; do - GOOGLE_MAPS_API_KEY_KEYCHAIN="$(security find-generic-password -s "google-maps-api" -a "${acct}" -w 2>/dev/null || true)" - if [[ -n "${GOOGLE_MAPS_API_KEY_KEYCHAIN}" ]]; then - GOOGLE_MAPS_API_KEY_SOURCE="keychain:${acct}" - break - fi - done -fi - -if [[ -n "${GOOGLE_MAPS_API_KEY_KEYCHAIN}" ]]; then - GOOGLE_MAPS_API_KEY="${GOOGLE_MAPS_API_KEY_KEYCHAIN}" -elif [[ -n "${GOOGLE_MAPS_API_KEY_ENV}" ]]; then - GOOGLE_MAPS_API_KEY="${GOOGLE_MAPS_API_KEY_ENV}" - GOOGLE_MAPS_API_KEY_SOURCE="env" -else - GOOGLE_MAPS_API_KEY="" -fi -if [[ -z "${GOOGLE_MAPS_API_KEY}" ]]; then - echo "error: Google Maps API key missing." - echo "set GOOGLE_MAPS_API_KEY in env, or add Keychain item: service=google-maps-api account=api-key" - exit 1 -fi - -read_keychain_secret() { - local service="$1" - local account="$2" - security find-generic-password -s "$service" -a "$account" -w 2>/dev/null || true -} - -load_opensky_oauth_from_file() { - local file_path="$1" - if [[ -z "${file_path}" || ! -f "${file_path}" ]]; then - return - fi - if ! command -v node >/dev/null 2>&1; then - echo "warning: node not found; cannot parse OPENSKY_CREDENTIALS_FILE" - return - fi - - local parsed - parsed="$(node -e "const fs=require('fs');const p=process.argv[1];try{const raw=JSON.parse(fs.readFileSync(p,'utf8'));const id=String(raw.clientId??raw.client_id??'').trim();const secret=String(raw.clientSecret??raw.client_secret??'').trim();process.stdout.write(id+'\\t'+secret);}catch{process.exit(1)}" "${file_path}" 2>/dev/null || true)" - if [[ "${parsed}" != *$'\t'* ]]; then - return - fi - local parsed_client_id="${parsed%%$'\t'*}" - local parsed_client_secret="${parsed#*$'\t'}" - - if [[ -z "${OPENSKY_CLIENT_ID}" && -n "${parsed_client_id}" ]]; then - OPENSKY_CLIENT_ID="${parsed_client_id}" - fi - if [[ -z "${OPENSKY_CLIENT_SECRET}" && -n "${parsed_client_secret}" ]]; then - OPENSKY_CLIENT_SECRET="${parsed_client_secret}" - fi -} - -resolve_opensky_credentials() { - OPENSKY_AUTH_MODE="$(printf '%s' "${OPENSKY_AUTH_MODE:-oauth}" | tr '[:upper:]' '[:lower:]')" - OPENSKY_CREDENTIALS_FILE="${OPENSKY_CREDENTIALS_FILE:-}" - case "${OPENSKY_AUTH_MODE}" in - basic|oauth|auto|anon) ;; - *) - echo "warning: invalid OPENSKY_AUTH_MODE='${OPENSKY_AUTH_MODE}', defaulting to 'oauth'" - OPENSKY_AUTH_MODE="oauth" - ;; - esac - - OPENSKY_CLIENT_ID="${OPENSKY_CLIENT_ID:-}" - OPENSKY_CLIENT_SECRET="${OPENSKY_CLIENT_SECRET:-}" - OPENSKY_USERNAME="${OPENSKY_USERNAME:-}" - OPENSKY_PASSWORD="${OPENSKY_PASSWORD:-}" - - if [[ "${OPENSKY_AUTH_MODE}" == "basic" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_USERNAME}" ]]; then - for svc in "opensky-network" "opensky"; do - for acct in "username" "user" "login" "email" "default"; do - OPENSKY_USERNAME="$(read_keychain_secret "${svc}" "${acct}")" - if [[ -n "${OPENSKY_USERNAME}" ]]; then - break 2 - fi - done - done - fi - if [[ "${OPENSKY_AUTH_MODE}" == "basic" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_PASSWORD}" ]]; then - for svc in "opensky-network" "opensky"; do - for acct in "password" "pass" "token" "secret" "default"; do - OPENSKY_PASSWORD="$(read_keychain_secret "${svc}" "${acct}")" - if [[ -n "${OPENSKY_PASSWORD}" ]]; then - break 2 - fi - done - done - fi - if [[ "${OPENSKY_AUTH_MODE}" == "oauth" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -n "${OPENSKY_CREDENTIALS_FILE}" ]]; then - load_opensky_oauth_from_file "${OPENSKY_CREDENTIALS_FILE}" - fi - if [[ "${OPENSKY_AUTH_MODE}" == "oauth" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_CLIENT_ID}" ]]; then - for svc in "opensky-network" "opensky"; do - for acct in "client_id" "client-id" "client" "api-key"; do - OPENSKY_CLIENT_ID="$(read_keychain_secret "${svc}" "${acct}")" - if [[ -n "${OPENSKY_CLIENT_ID}" ]]; then - break 2 - fi - done - done - fi - if [[ "${OPENSKY_AUTH_MODE}" == "oauth" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_CLIENT_SECRET}" ]]; then - for svc in "opensky-network" "opensky"; do - for acct in "client_secret" "client-secret" "secret"; do - OPENSKY_CLIENT_SECRET="$(read_keychain_secret "${svc}" "${acct}")" - if [[ -n "${OPENSKY_CLIENT_SECRET}" ]]; then - break 2 - fi - done - done - fi - - case "${OPENSKY_AUTH_MODE}" in - basic) - OPENSKY_CLIENT_ID="" - OPENSKY_CLIENT_SECRET="" - ;; - oauth) - OPENSKY_USERNAME="" - OPENSKY_PASSWORD="" - ;; - anon) - OPENSKY_CLIENT_ID="" - OPENSKY_CLIENT_SECRET="" - OPENSKY_USERNAME="" - OPENSKY_PASSWORD="" - ;; - esac -} - -resolve_opensky_credentials - -echo "Starting God's Eye View with CCTV source pack..." -echo "URL: http://localhost:${PORT}/" -echo "Google Maps key source: ${GOOGLE_MAPS_API_KEY_SOURCE}" -if [[ -f "${CCTV_SOURCES_FILE}" ]]; then - echo "CCTV_SOURCES_FILE: ${CCTV_SOURCES_FILE}" -else - echo "CCTV_SOURCES_FILE: ${CCTV_SOURCES_FILE} (missing, using Austin open-data feed discovery)" -fi -echo "OpenSky auth mode: ${OPENSKY_AUTH_MODE}" -if [[ -n "${OPENSKY_CREDENTIALS_FILE}" ]]; then - if [[ -f "${OPENSKY_CREDENTIALS_FILE}" ]]; then - echo "OpenSky credentials file: ${OPENSKY_CREDENTIALS_FILE}" - else - echo "OpenSky credentials file: ${OPENSKY_CREDENTIALS_FILE} (missing)" - fi -fi -case "${OPENSKY_AUTH_MODE}" in - basic) - if [[ -n "${OPENSKY_USERNAME}" && -n "${OPENSKY_PASSWORD}" ]]; then - echo "OpenSky basic auth: configured" - else - echo "OpenSky basic auth: missing credentials" - fi - ;; - oauth) - if [[ -n "${OPENSKY_CLIENT_ID}" && -n "${OPENSKY_CLIENT_SECRET}" ]]; then - echo "OpenSky OAuth: configured" - else - echo "OpenSky OAuth: missing client credentials" - fi - ;; - auto) - if [[ -n "${OPENSKY_CLIENT_ID}" && -n "${OPENSKY_CLIENT_SECRET}" ]]; then - echo "OpenSky auto auth: OAuth configured" - elif [[ -n "${OPENSKY_USERNAME}" && -n "${OPENSKY_PASSWORD}" ]]; then - echo "OpenSky auto auth: basic configured" - else - echo "OpenSky auto auth: no credentials found" - fi - ;; - anon) - echo "OpenSky auth: disabled (anonymous mode)" - ;; -esac - -GOOGLE_MAPS_API_KEY="${GOOGLE_MAPS_API_KEY}" \ -CCTV_SOURCES_FILE="${CCTV_SOURCES_FILE}" \ -CCTV_PREFER_AUSTIN="${CCTV_PREFER_AUSTIN}" \ -CCTV_AUSTIN_MAX_SOURCES="${CCTV_AUSTIN_MAX_SOURCES}" \ -CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES}" \ -OPENSKY_AUTH_MODE="${OPENSKY_AUTH_MODE}" \ -OPENSKY_CREDENTIALS_FILE="${OPENSKY_CREDENTIALS_FILE}" \ -OPENSKY_CLIENT_ID="${OPENSKY_CLIENT_ID}" \ -OPENSKY_CLIENT_SECRET="${OPENSKY_CLIENT_SECRET}" \ -OPENSKY_USERNAME="${OPENSKY_USERNAME}" \ -OPENSKY_PASSWORD="${OPENSKY_PASSWORD}" \ -npm run dev -- --host "${HOST}" --port "${PORT}" --force +exec bash "$ROOT_DIR/scripts/dev-fresh.sh" diff --git a/src/devCctv.test.mjs b/src/devCctv.test.mjs new file mode 100644 index 000000000..a333a4fe3 --- /dev/null +++ b/src/devCctv.test.mjs @@ -0,0 +1,80 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const run = promisify(execFile); +const bashTest = process.platform === 'win32' ? test.skip : test; + +async function launch(overrides = {}, dotenv = '') { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'gev-cctv-launch-')); + try { + await fs.mkdir(path.join(root, 'scripts')); + await fs.mkdir(path.join(root, 'bin')); + await fs.mkdir(path.join(root, 'src', 'data'), { recursive: true }); + await fs.copyFile(new URL('./data/cctv.js', import.meta.url), path.join(root, 'src', 'data', 'cctv.js')); + for (const name of ['dev-cctv.sh', 'dev-fresh.sh', 'read-dotenv-value.mjs']) { + await fs.copyFile(new URL(`../scripts/${name}`, import.meta.url), path.join(root, 'scripts', name)); + } + await fs.copyFile(new URL('./main.js', import.meta.url), path.join(root, 'src', 'main.js')); + await fs.mkdir(path.join(root, 'node_modules')); + await fs.symlink(fileURLToPath(new URL('../node_modules/vite', import.meta.url)), path.join(root, 'node_modules', 'vite'), 'dir'); + await fs.writeFile(path.join(root, '.env'), dotenv); + // Stub only external programs; both production launchers and dotenv parsing run. + for (const command of ['security', 'pkill', 'lsof']) { + await fs.writeFile(path.join(root, 'bin', command), '#!/bin/sh\nexit 1\n', { mode: 0o755 }); + } + await fs.writeFile(path.join(root, 'bin', 'npm'), `#!/usr/bin/env node +const fs = require('node:fs'); +fs.writeFileSync(process.env.CCTV_TEST_CAPTURE, JSON.stringify({ args: process.argv.slice(2), env: process.env, cwd: process.cwd() })); +`, { mode: 0o755 }); + const capture = path.join(root, 'capture.json'); + const result = await run('bash', [path.join(root, 'scripts', 'dev-cctv.sh')], { + cwd: os.tmpdir(), + env: { PATH: `${path.join(root, 'bin')}${path.delimiter}${process.env.PATH}`, CCTV_TEST_CAPTURE: capture, ...overrides }, + timeout: 30_000, + }); + return { ...JSON.parse(await fs.readFile(capture, 'utf8')), output: result.stdout + result.stderr, root }; + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +} + +bashTest('CCTV preset starts keyless on localhost through the normal launcher', async () => { + const result = await launch(); + assert.deepEqual(result.args, ['run', 'dev', '--', '--host', 'localhost', '--port', '4173', '--force']); + assert.equal(result.cwd, result.root); + assert.equal(result.env.CCTV_SOURCES_FILE, 'config/cctv_sources.austin.json'); + assert.equal(result.env.CCTV_PREFER_AUSTIN, '1'); + assert.equal(result.env.CCTV_AUSTIN_MAX_SOURCES, '36'); + assert.equal(result.env.CCTV_MAX_SOURCES, '48'); + assert.equal(result.env.GEV_LAUNCHER, 'dev-fresh'); + assert.equal(result.env.GEV_KEY_SETUP_EXTERNAL_KEYS, ''); + assert.equal(result.env.GOOGLE_MAPS_API_KEY, undefined); + assert.match(result.output, /Startup map: Esri World Imagery/); + assert.doesNotMatch(result.output, /!! WARNING/); +}); + +bashTest('CCTV preset preserves explicit LAN and source overrides with a warning', async () => { + const result = await launch({ HOST: '0.0.0.0', PORT: '4999', CCTV_SOURCES_FILE: 'config/custom.json', CCTV_PREFER_AUSTIN: '0', CCTV_AUSTIN_MAX_SOURCES: '5', CCTV_MAX_SOURCES: '9', CCTV_CALTRANS_DISTRICTS: '', CCTV_TFL_ENABLED: '0' }); + assert.deepEqual(result.args.slice(-5), ['--host', '0.0.0.0', '--port', '4999', '--force']); + assert.equal(result.env.CCTV_SOURCES_FILE, 'config/custom.json'); + assert.equal(result.env.CCTV_PREFER_AUSTIN, '0'); + assert.equal(result.env.CCTV_AUSTIN_MAX_SOURCES, '5'); + assert.equal(result.env.CCTV_MAX_SOURCES, '9'); + assert.equal(result.env.CCTV_CALTRANS_DISTRICTS, ''); + assert.equal(result.env.CCTV_TFL_ENABLED, '0'); + assert.match(result.output, /!! WARNING: HOST=0\.0\.0\.0/); +}); + +bashTest('CCTV preset shares dotenv precedence and names-only credential provenance', async () => { + const result = await launch({ GOOGLE_MAPS_API_KEY: 'fixture-shell-maps' }, 'GOOGLE_MAPS_API_KEY=fixture-file-maps\nOPENAI_API_KEY=fixture-file-voice\n'); + assert.equal(result.env.GOOGLE_MAPS_API_KEY, 'fixture-shell-maps'); + assert.equal(result.env.OPENAI_API_KEY, 'fixture-file-voice'); + assert.equal(result.env.GEV_KEY_SETUP_EXTERNAL_KEYS, 'GOOGLE_MAPS_API_KEY'); + assert.doesNotMatch(result.output, /fixture-shell-maps|fixture-file-maps|fixture-file-voice/); +}); diff --git a/src/proxyErrorResponses.test.mjs b/src/proxyErrorResponses.test.mjs new file mode 100644 index 000000000..8b2e5d0ff --- /dev/null +++ b/src/proxyErrorResponses.test.mjs @@ -0,0 +1,170 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +const source = readFileSync(new URL('../vite.config.js', import.meta.url), 'utf8'); +const detail = 'fixture-secret-token /internal/example '; + +// Execute the production middleware with isolated upstreams and cache storage. +// Top-level function closing braces start in column zero in this module. +function extract(name) { + const start = source.search(new RegExp(`(?:export )?(?:async )?function ${name}\\(`)); + assert.ok(start >= 0, `${name} must exist`); + const end = source.indexOf('\n}', start); + return source.slice(start, end + 2).replace(/^export /, ''); +} + +function fixture(name, overrides = {}, preview = false) { + const logs = []; + const deps = { + path, process: { cwd: () => '/fixture', env: {} }, + fsp: { + readFile: async () => { throw new Error('cache absent'); }, + stat: async () => { throw new Error('cache absent'); }, + mkdir: async () => {}, writeFile: async () => {}, + }, + fetch: async () => { throw new Error(detail); }, + console: { warn: (...args) => logs.push(args.join(' ')), error: (...args) => logs.push(args.join(' ')) }, + setInterval: () => ({ unref() {} }), + LL2_CACHE_TTL_MS: 15 * 60_000, + parseTerrainPoints: () => [[1, 2]], + resolveTerrainHeightRequest: async () => { throw new Error(detail); }, + ...overrides, + }; + const helpers = ['readResponseTextCapped', 'coalesceProxyRequest', 'launchLibraryRequestHeaders'].map(extract).join('\n'); + const plugin = new Function(...Object.keys(deps), `${helpers}\n${extract(name)}\nreturn ${name}();`)(...Object.values(deps)); + let middleware; + plugin[preview ? 'configurePreviewServer' : 'configureServer']({ middlewares: { use(_route, handler) { middleware = handler; } } }); + return { + logs, + async request(url = '/', method = 'GET') { + const response = { headersSent: false, writeHead(status, headers) { Object.assign(this, { status, headers, headersSent: true }); }, end(body) { this.body = body; } }; + await middleware({ url, method }, response); + assert.doesNotMatch(response.body, /fixture-secret-token|internal\/example|/); + assert.doesNotMatch(logs.join('\n'), /fixture-secret-token|internal\/example|/); + return response; + }, + }; +} + +for (const status of [401, 429, 500]) { + for (const preview of [false, true]) { + test(`Launch Library ${status} stays generic in ${preview ? 'preview' : 'development'}`, async () => { + const app = fixture('rocketLaunchesProxy', { fetch: async () => new Response(detail.repeat(1000), { status }) }, preview); + const res = await app.request(); + assert.equal(res.status, status); + assert.deepEqual(JSON.parse(res.body), { error: 'Launch Library 2 unavailable' }); + assert.equal(res.headers['Cache-Control'], 'no-store'); + assert.equal(res.headers['X-GEV-Cache'], 'NONE'); + assert.equal(app.logs.length, 1); + assert.match(app.logs[0], new RegExp(`HTTP ${status}`)); + assert.ok(app.logs[0].length < 100); + }); + } +} + +for (const [label, fetch] of [ + ['network error', async () => { throw new Error(detail); }], + ['malformed JSON', async () => new Response(detail)], + ['invalid feed', async () => new Response('{}')], + ['oversized response', async () => new Response(detail, { headers: { 'content-length': String(13 * 1024 * 1024) } })], +]) { + test(`Launch Library ${label} returns 502`, async () => { + const res = await fixture('rocketLaunchesProxy', { fetch }).request(); + assert.equal(res.status, 502); + assert.deepEqual(JSON.parse(res.body), { error: 'Launch Library 2 unavailable' }); + }); +} + +test('Launch Library retains single-flight, fresh cache, stale fallback, and method guard', async () => { + let now = Date.now(); + let calls = 0; + let release; + const gate = new Promise(resolve => { release = resolve; }); + class Clock extends Date { static now() { return now; } } + const app = fixture('rocketLaunchesProxy', { + Date: Clock, + fetch: async () => { calls += 1; await gate; if (calls > 1) throw new Error(detail); return new Response('{"results":[]}'); }, + }); + assert.equal((await app.request('/', 'POST')).status, 405); + const first = app.request(); + const second = app.request(); + release(); + const pair = await Promise.all([first, second]); + assert.deepEqual(pair.map(res => res.headers['X-GEV-Cache']).sort(), ['INFLIGHT', 'MISS']); + assert.equal(calls, 1); + assert.equal((await app.request()).headers['X-GEV-Cache'], 'HIT'); + now += 16 * 60_000; + const stale = await app.request(); + assert.equal(stale.status, 200); + assert.equal(stale.body, '{"results":[]}'); + assert.equal(stale.headers['X-GEV-Cache'], 'STALE-ERROR'); + assert.equal(calls, 2); +}); + +test('CelesTrak unexpected failures hide details', async () => { + const app = fixture('celestrakProxy', { Date: { now() { throw new Error(detail); } } }); + const res = await app.request('/active'); + assert.equal(res.status, 500); + assert.equal(res.body, 'celestrak proxy error'); + assert.equal(res.headers['x-tle-cache'], 'ERROR'); +}); + +test('CelesTrak retains invalid-group and unavailable responses', async () => { + const app = fixture('celestrakProxy'); + assert.equal((await app.request('/../')).status, 400); + const res = await app.request('/active'); + assert.equal(res.status, 502); + assert.equal(res.headers['x-tle-cache'], 'NONE'); +}); + +test('CelesTrak retains fresh and stale TLE caches', async () => { + let now = Date.now(); + let calls = 0; + const app = fixture('celestrakProxy', { + Date: { now: () => now }, + fetch: async () => { if (++calls > 1) throw new Error(detail); return new Response('1 valid-fixture-TLE'); }, + }); + assert.equal((await app.request('/active')).headers['x-tle-cache'], 'MISS'); + assert.equal((await app.request('/active')).headers['x-tle-cache'], 'HIT'); + now += 7 * 3600_000; + const stale = await app.request('/active'); + assert.equal(stale.status, 200); + assert.equal(stale.body, '1 valid-fixture-TLE'); + assert.equal(stale.headers['x-tle-cache'], 'STALE-ERROR'); +}); + +test('terrain unexpected failures hide details', async () => { + const res = await fixture('terrainHeightsProxy').request('/?points=1,2'); + assert.equal(res.status, 500); + assert.deepEqual(JSON.parse(res.body), { error: 'terrain heights proxy error' }); +}); + +test('terrain validation and resolver outcomes remain intact', async () => { + const invalid = await fixture('terrainHeightsProxy', { parseTerrainPoints: () => null }).request(); + assert.equal(invalid.status, 400); + const body = { results: [{ height: 12 }] }; + const app = fixture('terrainHeightsProxy', { resolveTerrainHeightRequest: async () => ({ status: 200, body, upstreamError: new Error(detail) }) }); + const res = await app.request('/?points=1,2'); + assert.equal(res.status, 200); + assert.deepEqual(JSON.parse(res.body), body); + assert.equal(app.logs.length, 1); +}); + +test('ADSBDB unexpected failures hide details', async () => { + const badUrl = { toString() { throw new Error(detail); } }; + const res = await fixture('adsbdbProxy').request(badUrl); + assert.equal(res.status, 500); + assert.deepEqual(JSON.parse(res.body), { error: 'adsbdb proxy error' }); +}); + +test('ADSBDB retains validation and missing-aircraft semantics', async () => { + const app = fixture('adsbdbProxy'); + assert.equal((await app.request('/route/!')).status, 400); + assert.equal((await app.request('/type/nope')).status, 400); + assert.equal((await app.request('/unknown')).status, 404); + const absent = await app.request('/type/abcdef'); + assert.equal(absent.status, 200); + assert.deepEqual(JSON.parse(absent.body), { found: false }); +}); diff --git a/vite.config.js b/vite.config.js index 0ad4f2c37..73a296247 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1572,7 +1572,7 @@ function celestrakProxy() { await fsp.mkdir(CACHE_DIR, { recursive: true }); await fsp.writeFile(diskPath(group), JSON.stringify(entry), 'utf8'); } catch (err) { - console.warn(`[celestrak-proxy] cache write failed for ${group}:`, err?.message || err); + console.warn('[celestrak-proxy] cache write failed'); } } @@ -1631,7 +1631,7 @@ function celestrakProxy() { return fresh; }) .catch((err) => { - console.warn(`[celestrak-proxy] ${group} refresh failed (${err?.message || err}) — serving cache if any`); + console.warn('[celestrak-proxy] refresh failed — serving cache if any'); return null; }) .finally(() => inflight.delete(group))); @@ -1645,7 +1645,8 @@ function celestrakProxy() { send(502, 'celestrak fetch failed and no cache available', 'NONE'); } } catch (err) { - send(500, `celestrak proxy error: ${err?.message || err}`, 'ERROR'); + console.error('[celestrak-proxy] request failed'); + send(500, 'celestrak proxy error', 'ERROR'); } }); }, @@ -1692,7 +1693,7 @@ function rocketLaunchesProxy() { await fsp.mkdir(path.dirname(cachePath), { recursive: true }); await fsp.writeFile(cachePath, JSON.stringify(entry), 'utf8'); } catch (error) { - console.warn(`[launch-library-proxy] cache write failed: ${error?.message || error}`); + console.warn('[launch-library-proxy] cache write failed'); } } @@ -1721,7 +1722,6 @@ function rocketLaunchesProxy() { if (!upstream.ok) { const error = new Error(`upstream HTTP ${upstream.status}`); error.upstreamStatus = upstream.status; - error.upstreamBody = body; throw error; } const parsed = JSON.parse(body); @@ -1750,15 +1750,17 @@ function rocketLaunchesProxy() { const fresh = await request.promise; send(res, 200, fresh.body, request.shared ? 'INFLIGHT' : 'MISS'); } catch (error) { + // Log only a bounded status, never upstream bodies, URLs, or credentials. + const status = Number.isInteger(error?.upstreamStatus) ? error.upstreamStatus : 502; + if (!request.shared) console.warn(`[launch-library-proxy] refresh failed (HTTP ${status})${stale ? ' — serving stale cache' : ''}`); if (stale) { - if (!request.shared) console.warn(`[launch-library-proxy] refresh failed (${error?.message || error}) — serving stale cache`); send(res, 200, stale.body, 'STALE-ERROR'); return; } send( res, - Number.isInteger(error?.upstreamStatus) ? error.upstreamStatus : 502, - error?.upstreamBody || JSON.stringify({ error: 'Launch Library 2 unavailable' }), + status, + JSON.stringify({ error: 'Launch Library 2 unavailable' }), 'NONE', ); } @@ -2296,7 +2298,7 @@ function terrainHeightsProxy() { await fsp.writeFile(CACHE_PATH, JSON.stringify(obj), 'utf8'); } catch (err) { diskDirty = true; // retry next tick - console.warn('[terrain-heights-proxy] cache write failed:', err?.message || err); + console.warn('[terrain-heights-proxy] cache write failed'); } }, 15_000).unref?.(); } @@ -2367,13 +2369,14 @@ function terrainHeightsProxy() { if (outcome.cacheChanged) diskDirty = true; if (outcome.upstreamError) { console.warn( - `[terrain-heights-proxy] refresh incomplete (${outcome.upstreamError?.message || outcome.upstreamError})` + '[terrain-heights-proxy] refresh incomplete' + ' — serving stale points when available' ); } send(outcome.status, outcome.body); } catch (err) { - send(500, { error: `terrain heights proxy error: ${err?.message || err}` }); + console.error('[terrain-heights-proxy] request failed'); + send(500, { error: 'terrain heights proxy error' }); } }); }, @@ -2494,7 +2497,8 @@ function adsbdbProxy() { } return send(404, { error: 'unknown endpoint' }); } catch (err) { - return send(500, { error: String(err?.message || err) }); + console.error('[adsbdb-proxy] request failed'); + return send(500, { error: 'adsbdb proxy error' }); } }); },