learning_ai_invt_trdg/backend/verifyMarketDataEndpoints.ts
Saravana Achu Mac 1377bf2453 fix(C6): require explicit FMP API key
Remove the silent shared demo-key fallback for FMP-backed research and screener routes, document the required key, and make backend/.env.example trackable so setup guidance has one source of truth.

Refs: docs/AUDIT_REDESIGN.md item C6.

Co-Authored-By: GPT-5 Codex <noreply@openai.com>
2026-05-04 17:01:00 -07:00

237 lines
9.1 KiB
TypeScript

/**
* verifyMarketDataEndpoints.ts
*
* Static contract checks for the dashboard market-data proxy endpoints.
* These routes are thin upstream proxies, so the high-value regression
* surface is auth, provider URL construction, normalization, and cache usage.
*/
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const apiServerSource = readFileSync(join(__dirname, 'src/services/apiServer.ts'), 'utf8');
const configSource = readFileSync(join(__dirname, 'src/config/index.ts'), 'utf8');
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function assertRouteRequiresAuth(route: string) {
const routePattern = new RegExp(
`this\\.app\\.get\\('${escapeRegExp(route)}',\\s*this\\.requireAuth,`,
);
assert.match(
apiServerSource,
routePattern,
`${route} must be registered with requireAuth middleware`,
);
}
function assertSourceIncludes(fragment: string, message: string) {
assert.ok(apiServerSource.includes(fragment), message);
}
function assertConfigIncludes(fragment: string, message: string) {
assert.ok(configSource.includes(fragment), message);
}
function assertSourceMatches(pattern: RegExp, message: string) {
assert.match(apiServerSource, pattern, message);
}
function testRoutesRequireAuth() {
for (const route of [
'/api/chart/bars',
'/api/news',
'/api/market/indices',
'/api/research/profile',
'/api/research/metrics',
'/api/research/earnings',
'/api/screener',
]) {
assertRouteRequiresAuth(route);
}
console.log('[PASS] Market-data endpoints require authenticated requests.');
}
function testChartBarsContract() {
assertSourceMatches(
/this\.app\.get\('\/api\/chart\/bars'[\s\S]*if \(!symbol\)[\s\S]*status\(400\)\.json\(\{ error: 'symbol required' \}\)/,
'/api/chart/bars must reject missing symbols',
);
assertSourceMatches(
/this\.app\.get\('\/api\/chart\/bars'[\s\S]*Alpaca credentials not configured/,
'/api/chart/bars must fail closed when Alpaca credentials are absent',
);
for (const [period, timeframe, limit] of [
['1D', '5Min', '100'],
['5D', '15Min', '200'],
['1M', '1Day', '31'],
['3M', '1Day', '92'],
['6M', '1Day', '183'],
['YTD', '1Day', '365'],
['1Y', '1Day', '365'],
['5Y', '1Week', '260'],
['MAX', '1Month', '240'],
]) {
assertSourceMatches(
new RegExp(`case '${period}':[\\s\\S]*timeframe = '${timeframe}';\\s*limit = ${limit};`),
`/api/chart/bars must preserve ${period} -> ${timeframe}/${limit} mapping`,
);
}
assertSourceIncludes(
"symbol.includes('/')",
'/api/chart/bars must detect crypto symbols separately from equities',
);
assertSourceIncludes(
'https://data.alpaca.markets/v1beta3/crypto/us/bars?symbols=${encodedSymbol}&${qs.toString()}',
'/api/chart/bars must call Alpaca crypto bars with symbols query',
);
assertSourceIncludes(
'https://data.alpaca.markets/v2/stocks/${encodedSymbol}/bars?${qs.toString()}',
'/api/chart/bars must call Alpaca stock bars endpoint',
);
assertSourceIncludes(
"qs.set('feed', 'iex')",
'/api/chart/bars stock requests must use IEX feed',
);
assertSourceIncludes(
'rawBars = cryptoBars[symbol] ?? Object.values(cryptoBars)[0] ?? []',
'/api/chart/bars must unwrap crypto keyed bar responses',
);
assertSourceMatches(
/const bars = rawBars\.map[\s\S]*ts:\s+new Date\(b\.t\)\.getTime\(\)[\s\S]*open:\s+b\.o[\s\S]*high:\s+b\.h[\s\S]*low:\s+b\.l[\s\S]*close:\s+b\.c[\s\S]*volume:\s*b\.v/,
'/api/chart/bars must normalize Alpaca bar fields for the web chart',
);
console.log('[PASS] /api/chart/bars contract is covered.');
}
function testAlpacaProxyContracts() {
assertSourceMatches(
/this\.app\.get\('\/api\/news'[\s\S]*Math\.max\(1, Math\.min\(50, Number\(req\.query\.limit\) \|\| 10\)\)/,
'/api/news must clamp limit to 1..50',
);
assertSourceIncludes(
'const NEWS_SYMBOL_PATTERN = /^[A-Z0-9][A-Z0-9./-]{0,15}$/;',
'/api/news must constrain symbols to expected characters and length',
);
assertSourceIncludes(
'const MAX_NEWS_SYMBOLS = 10;',
'/api/news must limit the number of symbols sent upstream',
);
assertSourceMatches(
/symbols = normalizeNewsSymbolsQuery\(req\.query\.symbols\)[\s\S]*status\(400\)\.json\(\{ error: validationError\.message \}\)/,
'/api/news must reject invalid symbol queries before proxying to Alpaca',
);
assertSourceIncludes(
'https://data.alpaca.markets/v1beta1/news?${qs.toString()}',
'/api/news must proxy Alpaca news',
);
assertSourceIncludes("sort: 'desc'", '/api/news must request newest stories first');
assertSourceIncludes(
'res.json({ news: data.news ?? data })',
'/api/news must normalize responses to a news array wrapper',
);
assertSourceIncludes(
'https://data.alpaca.markets/v2/stocks/snapshots?symbols=SPY,DIA,QQQ&feed=iex',
'/api/market/indices must request SPY/DIA/QQQ IEX snapshots',
);
assertSourceIncludes(
'Alpaca snapshots fetch failed',
'/api/market/indices must propagate Alpaca snapshot failures',
);
console.log('[PASS] Alpaca news and index proxy contracts are covered.');
}
function testFmpProxyContracts() {
assertSourceIncludes(
"import { fetchFmpJson, FmpFetchError } from './fmpCache.js';",
'FMP routes must use the shared cache helper and typed upstream error',
);
assertConfigIncludes(
"FMP_API_KEY: process.env.FMP_API_KEY || '',",
'FMP config must not silently default to the shared demo key',
);
assertSourceIncludes(
"apiKey.toLowerCase() === 'demo'",
'FMP routes must reject the shared demo key explicitly',
);
assertSourceIncludes(
'FMP_API_KEY is required for research and screener endpoints',
'FMP routes must surface an explicit missing-key error',
);
const fetchFmpCalls = apiServerSource.match(/fetchFmpJson\(url\)/g) ?? [];
assert.equal(fetchFmpCalls.length, 4, 'each FMP route must fetch through fetchFmpJson(url)');
for (const [route, endpoint, errorMessage] of [
['/api/research/profile', '/api/v3/profile/${symbol}?apikey=${apiKey}', 'FMP profile fetch failed'],
['/api/research/metrics', '/api/v3/key-metrics/${symbol}?limit=4&apikey=${apiKey}', 'FMP metrics fetch failed'],
['/api/research/earnings', '/api/v3/historical/earning_calendar/${symbol}?limit=8&apikey=${apiKey}', 'FMP earnings fetch failed'],
]) {
assertSourceMatches(
new RegExp(`this\\.app\\.get\\('${escapeRegExp(route)}'[\\s\\S]*if \\(!symbol\\)[\\s\\S]*status\\(400\\)\\.json\\(\\{ error: 'symbol required' \\}\\)`),
`${route} must reject missing symbols`,
);
assertSourceIncludes(endpoint, `${route} must call the expected FMP endpoint`);
assertSourceIncludes(errorMessage, `${route} must preserve its FMP upstream error message`);
}
assertSourceIncludes(
'res.json(Array.isArray(data) ? data[0] ?? {} : data)',
'profile and metrics endpoints must unwrap first FMP array result',
);
assertSourceIncludes(
'res.json({ earnings: Array.isArray(data) ? data : [] })',
'/api/research/earnings must normalize to an earnings array wrapper',
);
assertSourceIncludes(
'https://financialmodelingprep.com/api/v3/stock-screener?${qs.toString()}',
'/api/screener must call FMP stock-screener',
);
assertSourceIncludes(
'const ALLOWED_SCREENER_SECTORS = new Set([',
'/api/screener must keep an explicit sector allow-list',
);
assertSourceMatches(
/if \(sector && sector !== 'All'\)[\s\S]*!ALLOWED_SCREENER_SECTORS\.has\(sector\)[\s\S]*status\(400\)\.json\(\{ error: 'Unsupported sector filter' \}\)[\s\S]*qs\.set\('sector', sector\)/,
'/api/screener must reject unsupported sector values before building the FMP query',
);
assertSourceIncludes(
"qs.set('isEtf', 'false')",
'/api/screener must exclude ETFs by default',
);
assertSourceIncludes(
'String(Math.min(100, Number(req.query.limit) || 50))',
'/api/screener must cap result limit at 100',
);
assertSourceIncludes(
'res.json({ results: Array.isArray(data) ? data : [] })',
'/api/screener must normalize to a results array wrapper',
);
assertSourceIncludes(
'FMP screener fetch failed',
'/api/screener must preserve its FMP upstream error message',
);
console.log('[PASS] FMP research and screener proxy contracts are covered.');
}
function main() {
testRoutesRequireAuth();
testChartBarsContract();
testAlpacaProxyContracts();
testFmpProxyContracts();
console.log('Market-data endpoint contract checks passed');
}
main();