mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
fix: share CCTV startup and keep proxy diagnostics bounded
This commit is contained in:
1 parent
8fc86e0780
commit
f47cd792d1
6 files changed
+293
-217
No files matched your search
@@ -13,6 +13,14 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
### Fixed
|
||||||
|
|
||||||
- Map Source keyboard opening retries focus until the selected tile is visible.
|
- Map Source keyboard opening retries focus until the selected tile is visible.
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
# God's Eye View Current State
|
# 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
|
## Map Source keyboard focus
|
||||||
|
|
||||||
Keyboard opening focuses the selected map-source tile, falling back to the first
|
Keyboard opening focuses the selected map-source tile, falling back to the first
|
||||||
|
|||||||
+7
-209
@@ -2,214 +2,12 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
cd "$ROOT_DIR"
|
|
||||||
|
|
||||||
PORT="${PORT:-4173}"
|
# A lighter CCTV testing preset; startup, credentials, and LAN warnings belong
|
||||||
# Local-only by default; set HOST=0.0.0.0 explicitly to expose on the LAN.
|
# to the normal launcher. Explicit environment overrides remain supported.
|
||||||
HOST="${HOST:-localhost}"
|
export CCTV_SOURCES_FILE="${CCTV_SOURCES_FILE:-config/cctv_sources.austin.json}"
|
||||||
CCTV_SOURCES_FILE="${CCTV_SOURCES_FILE:-config/cctv_sources.austin.json}"
|
export CCTV_PREFER_AUSTIN="${CCTV_PREFER_AUSTIN:-1}"
|
||||||
CCTV_PREFER_AUSTIN="${CCTV_PREFER_AUSTIN:-1}"
|
export CCTV_AUSTIN_MAX_SOURCES="${CCTV_AUSTIN_MAX_SOURCES:-36}"
|
||||||
CCTV_AUSTIN_MAX_SOURCES="${CCTV_AUSTIN_MAX_SOURCES:-36}"
|
export CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-48}"
|
||||||
CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-48}"
|
|
||||||
|
|
||||||
if ! command -v npm >/dev/null 2>&1; then
|
exec bash "$ROOT_DIR/scripts/dev-fresh.sh"
|
||||||
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
|
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
@@ -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 <html>';
|
||||||
|
|
||||||
|
// 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|<html>/);
|
||||||
|
assert.doesNotMatch(logs.join('\n'), /fixture-secret-token|internal\/example|<html>/);
|
||||||
|
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 });
|
||||||
|
});
|
||||||
+12
-8
@@ -1572,7 +1572,7 @@ function celestrakProxy() {
|
|||||||
await fsp.mkdir(CACHE_DIR, { recursive: true });
|
await fsp.mkdir(CACHE_DIR, { recursive: true });
|
||||||
await fsp.writeFile(diskPath(group), JSON.stringify(entry), 'utf8');
|
await fsp.writeFile(diskPath(group), JSON.stringify(entry), 'utf8');
|
||||||
} catch (err) {
|
} 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;
|
return fresh;
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.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;
|
return null;
|
||||||
})
|
})
|
||||||
.finally(() => inflight.delete(group)));
|
.finally(() => inflight.delete(group)));
|
||||||
@@ -1645,6 +1645,7 @@ function celestrakProxy() {
|
|||||||
send(502, 'celestrak fetch failed and no cache available', 'NONE');
|
send(502, 'celestrak fetch failed and no cache available', 'NONE');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error('[celestrak-proxy] request failed');
|
||||||
send(500, 'celestrak proxy error', 'ERROR');
|
send(500, 'celestrak proxy error', 'ERROR');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1692,7 +1693,7 @@ function rocketLaunchesProxy() {
|
|||||||
await fsp.mkdir(path.dirname(cachePath), { recursive: true });
|
await fsp.mkdir(path.dirname(cachePath), { recursive: true });
|
||||||
await fsp.writeFile(cachePath, JSON.stringify(entry), 'utf8');
|
await fsp.writeFile(cachePath, JSON.stringify(entry), 'utf8');
|
||||||
} catch (error) {
|
} 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) {
|
if (!upstream.ok) {
|
||||||
const error = new Error(`upstream HTTP ${upstream.status}`);
|
const error = new Error(`upstream HTTP ${upstream.status}`);
|
||||||
error.upstreamStatus = upstream.status;
|
error.upstreamStatus = upstream.status;
|
||||||
error.upstreamBody = body;
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
const parsed = JSON.parse(body);
|
const parsed = JSON.parse(body);
|
||||||
@@ -1750,14 +1750,16 @@ function rocketLaunchesProxy() {
|
|||||||
const fresh = await request.promise;
|
const fresh = await request.promise;
|
||||||
send(res, 200, fresh.body, request.shared ? 'INFLIGHT' : 'MISS');
|
send(res, 200, fresh.body, request.shared ? 'INFLIGHT' : 'MISS');
|
||||||
} catch (error) {
|
} 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 (stale) {
|
||||||
if (!request.shared) console.warn(`[launch-library-proxy] refresh failed (${error?.message || error}) — serving stale cache`);
|
|
||||||
send(res, 200, stale.body, 'STALE-ERROR');
|
send(res, 200, stale.body, 'STALE-ERROR');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
send(
|
send(
|
||||||
res,
|
res,
|
||||||
Number.isInteger(error?.upstreamStatus) ? error.upstreamStatus : 502,
|
status,
|
||||||
JSON.stringify({ error: 'Launch Library 2 unavailable' }),
|
JSON.stringify({ error: 'Launch Library 2 unavailable' }),
|
||||||
'NONE',
|
'NONE',
|
||||||
);
|
);
|
||||||
@@ -2296,7 +2298,7 @@ function terrainHeightsProxy() {
|
|||||||
await fsp.writeFile(CACHE_PATH, JSON.stringify(obj), 'utf8');
|
await fsp.writeFile(CACHE_PATH, JSON.stringify(obj), 'utf8');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
diskDirty = true; // retry next tick
|
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?.();
|
}, 15_000).unref?.();
|
||||||
}
|
}
|
||||||
@@ -2367,12 +2369,13 @@ function terrainHeightsProxy() {
|
|||||||
if (outcome.cacheChanged) diskDirty = true;
|
if (outcome.cacheChanged) diskDirty = true;
|
||||||
if (outcome.upstreamError) {
|
if (outcome.upstreamError) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`[terrain-heights-proxy] refresh incomplete (${outcome.upstreamError?.message || outcome.upstreamError})`
|
'[terrain-heights-proxy] refresh incomplete'
|
||||||
+ ' — serving stale points when available'
|
+ ' — serving stale points when available'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
send(outcome.status, outcome.body);
|
send(outcome.status, outcome.body);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error('[terrain-heights-proxy] request failed');
|
||||||
send(500, { error: 'terrain heights proxy error' });
|
send(500, { error: 'terrain heights proxy error' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -2494,6 +2497,7 @@ function adsbdbProxy() {
|
|||||||
}
|
}
|
||||||
return send(404, { error: 'unknown endpoint' });
|
return send(404, { error: 'unknown endpoint' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error('[adsbdb-proxy] request failed');
|
||||||
return send(500, { error: 'adsbdb proxy error' });
|
return send(500, { error: 'adsbdb proxy error' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in new issue
Block a user