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>
237 lines
9.1 KiB
TypeScript
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();
|