- Call loadDynamicConfig() without dead supabaseService argument (Cosmos-backed). - Use getLegacySupabaseClient() for raw .from() queries in maintenance scripts. - manualOverrideCloseTrades: typed imports + legacy client for lifecycle SELECT. - verify_realtime: ESM .js imports and comment for subscribeToProfiles. - verifyTenantIsolation: comment for singleton monkey-patch. Made-with: Cursor
230 lines
9.4 KiB
TypeScript
230 lines
9.4 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { ApiServer } from './src/services/apiServer.js';
|
|
// Direct supabaseService: monkey-patches loadLatestBotStateSnapshot on the singleton for isolation checks.
|
|
import { supabaseService } from './src/services/SupabaseService.js';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
const repoRoot = __dirname;
|
|
|
|
async function verifyStaticIsolationGuards(): Promise<void> {
|
|
const apiServerSource = fs.readFileSync(path.join(repoRoot, 'src/services/apiServer.ts'), 'utf8');
|
|
|
|
assert(!/this\.io\.emit\(/.test(apiServerSource), 'Global websocket broadcasts detected (this.io.emit).');
|
|
assert(!/socket\.emit\('state',\s*this\.state\)/.test(apiServerSource), 'Global runtime state push detected on websocket connect.');
|
|
assert(/private\s+getScopedState\s*\(/.test(apiServerSource), 'Scoped state projector missing.');
|
|
assert(/private\s+emitToConnectedUsers/.test(apiServerSource), 'Tenant-scoped websocket emitter missing.');
|
|
}
|
|
|
|
async function verifyRuntimeIsolationGuards(): Promise<void> {
|
|
const originalStartServer = (ApiServer.prototype as any).startServer;
|
|
(ApiServer.prototype as any).startServer = () => undefined;
|
|
const originalLoadSnapshot = supabaseService.loadLatestBotStateSnapshot.bind(supabaseService);
|
|
(supabaseService as any).loadLatestBotStateSnapshot = async () => null;
|
|
|
|
const server = new ApiServer(0);
|
|
|
|
try {
|
|
const runtimeState = server.getState();
|
|
runtimeState.symbols = {};
|
|
runtimeState.positions = [];
|
|
runtimeState.orders = [];
|
|
runtimeState.history = [];
|
|
runtimeState.alerts = [];
|
|
|
|
server.registerManualTrader('profile-a', { getUserId: () => 'user-a' } as any);
|
|
server.registerManualTrader('profile-b', { getUserId: () => 'user-b' } as any);
|
|
|
|
server.updatePositions([
|
|
{
|
|
id: 'pos-a',
|
|
symbol: 'BTC/USD',
|
|
side: 'BUY',
|
|
size: 1,
|
|
entryPrice: 100,
|
|
currentPrice: 101,
|
|
stopLoss: 90,
|
|
takeProfit: 110,
|
|
unrealizedPnl: 1,
|
|
unrealizedPnlPercent: 1,
|
|
marketValue: 101,
|
|
userId: 'user-a',
|
|
profileId: 'profile-a',
|
|
profileName: 'Profile A',
|
|
tradeId: 'TRD-A-1'
|
|
}
|
|
], 'profile-a');
|
|
|
|
server.updatePositions([
|
|
{
|
|
id: 'pos-b',
|
|
symbol: 'BTC/USD',
|
|
side: 'BUY',
|
|
size: 2,
|
|
entryPrice: 100,
|
|
currentPrice: 102,
|
|
stopLoss: 90,
|
|
takeProfit: 110,
|
|
unrealizedPnl: 4,
|
|
unrealizedPnlPercent: 2,
|
|
marketValue: 204,
|
|
userId: 'user-b',
|
|
profileId: 'profile-b',
|
|
profileName: 'Profile B',
|
|
tradeId: 'TRD-B-1'
|
|
}
|
|
], 'profile-b');
|
|
|
|
server.updateOrders([
|
|
{
|
|
id: 'ord-a',
|
|
symbol: 'BTC/USD',
|
|
type: 'Market',
|
|
side: 'BUY',
|
|
qty: 1,
|
|
price: 100,
|
|
status: 'filled',
|
|
timestamp: Date.now(),
|
|
profileId: 'profile-a',
|
|
userId: 'user-a',
|
|
trade_id: 'TRD-A-1',
|
|
action: 'ENTRY',
|
|
source: 'BOT'
|
|
}
|
|
], 'profile-a');
|
|
|
|
server.updateOrders([
|
|
{
|
|
id: 'ord-b',
|
|
symbol: 'BTC/USD',
|
|
type: 'Market',
|
|
side: 'BUY',
|
|
qty: 2,
|
|
price: 100,
|
|
status: 'filled',
|
|
timestamp: Date.now(),
|
|
profileId: 'profile-b',
|
|
userId: 'user-b',
|
|
trade_id: 'TRD-B-1',
|
|
action: 'ENTRY',
|
|
source: 'BOT'
|
|
}
|
|
], 'profile-b');
|
|
|
|
server.addHistory({
|
|
symbol: 'BTC/USD',
|
|
side: 'BUY',
|
|
entryPrice: 100,
|
|
exitPrice: 105,
|
|
size: 1,
|
|
pnl: 5,
|
|
pnlPercent: 5,
|
|
reason: 'target',
|
|
timestamp: Date.now(),
|
|
userId: 'user-a',
|
|
profileId: 'profile-a',
|
|
trade_id: 'TRD-A-1',
|
|
source: 'BOT'
|
|
});
|
|
|
|
server.addHistory({
|
|
symbol: 'BTC/USD',
|
|
side: 'BUY',
|
|
entryPrice: 100,
|
|
exitPrice: 95,
|
|
size: 1,
|
|
pnl: -5,
|
|
pnlPercent: -5,
|
|
reason: 'stop',
|
|
timestamp: Date.now(),
|
|
userId: 'user-b',
|
|
profileId: 'profile-b',
|
|
trade_id: 'TRD-B-1',
|
|
source: 'BOT'
|
|
});
|
|
|
|
server.addAlert('info', 'BTC/USD', 'alert-a', { userId: 'user-a', profileId: 'profile-a' });
|
|
server.addAlert('info', 'BTC/USD', 'alert-b', { userId: 'user-b', profileId: 'profile-b' });
|
|
|
|
server.updateSymbol('BTC/USD', {
|
|
price: 101,
|
|
change24h: 0.5,
|
|
changeToday: 0.2,
|
|
session: 'NY',
|
|
volatility: 'Low',
|
|
signal: 'BUY',
|
|
profileSignals: {
|
|
'profile-a': {
|
|
profileName: 'Profile A',
|
|
signal: 'BUY',
|
|
passed: true,
|
|
reason: 'ok',
|
|
rules: {}
|
|
},
|
|
'profile-b': {
|
|
profileName: 'Profile B',
|
|
signal: 'SELL',
|
|
passed: true,
|
|
reason: 'ok',
|
|
rules: {}
|
|
}
|
|
},
|
|
activePosition: {
|
|
side: 'BUY',
|
|
entryPrice: 100,
|
|
size: 2,
|
|
stopLoss: 90,
|
|
takeProfit: 110,
|
|
userId: 'user-b',
|
|
profileId: 'profile-b',
|
|
tradeId: 'TRD-B-1',
|
|
profileName: 'Profile B'
|
|
},
|
|
indicators: {
|
|
ema20_1h: 99
|
|
},
|
|
rules: {}
|
|
});
|
|
const userAState = (server as any).getScopedState('user-a', false);
|
|
const userBState = (server as any).getScopedState('user-b', false);
|
|
|
|
assert.equal(userAState.positions.length, 1, 'User A should only receive one owned position.');
|
|
assert.equal(userAState.positions[0].profileId, 'profile-a', 'User A received foreign position.');
|
|
assert.equal(userAState.orders.length, 1, 'User A should only receive one owned order.');
|
|
assert.equal(userAState.orders[0].profileId, 'profile-a', 'User A received foreign order.');
|
|
assert.equal(userAState.history.length, 1, 'User A should only receive one owned history row.');
|
|
assert.equal(userAState.history[0].profileId, 'profile-a', 'User A received foreign history row.');
|
|
assert.equal(userAState.alerts.length, 1, 'User A should only receive one owned alert.');
|
|
assert.equal(userAState.alerts[0].profileId, 'profile-a', 'User A received foreign alert.');
|
|
assert.deepEqual(Object.keys(userAState.symbols['BTC/USD'].profileSignals || {}), ['profile-a'], 'User A received foreign profile signal.');
|
|
assert.equal(userAState.symbols['BTC/USD'].activePosition, null, 'User A should not receive User B active symbol position.');
|
|
|
|
assert.equal(userBState.positions.length, 1, 'User B should only receive one owned position.');
|
|
assert.equal(userBState.positions[0].profileId, 'profile-b', 'User B received foreign position.');
|
|
assert.equal(userBState.orders.length, 1, 'User B should only receive one owned order.');
|
|
assert.equal(userBState.orders[0].profileId, 'profile-b', 'User B received foreign order.');
|
|
assert.equal(userBState.history.length, 1, 'User B should only receive one owned history row.');
|
|
assert.equal(userBState.history[0].profileId, 'profile-b', 'User B received foreign history row.');
|
|
assert.equal(userBState.alerts.length, 1, 'User B should only receive one owned alert.');
|
|
assert.equal(userBState.alerts[0].profileId, 'profile-b', 'User B received foreign alert.');
|
|
assert.deepEqual(Object.keys(userBState.symbols['BTC/USD'].profileSignals || {}), ['profile-b'], 'User B received foreign profile signal.');
|
|
assert.equal(userBState.symbols['BTC/USD'].activePosition?.profileId, 'profile-b', 'User B should receive owned active symbol position.');
|
|
|
|
const symbolStateA = (server as any).getScopedSymbolState(runtimeState.symbols['BTC/USD'], 'user-a');
|
|
assert.deepEqual(Object.keys(symbolStateA.profileSignals || {}), ['profile-a'], 'Symbol endpoint leaked foreign profile signal to User A.');
|
|
|
|
const symbolStateB = (server as any).getScopedSymbolState(runtimeState.symbols['BTC/USD'], 'user-b');
|
|
assert.deepEqual(Object.keys(symbolStateB.profileSignals || {}), ['profile-b'], 'Symbol endpoint leaked foreign profile signal to User B.');
|
|
} finally {
|
|
(ApiServer.prototype as any).startServer = originalStartServer;
|
|
(supabaseService as any).loadLatestBotStateSnapshot = originalLoadSnapshot;
|
|
}
|
|
}
|
|
|
|
await verifyStaticIsolationGuards();
|
|
await verifyRuntimeIsolationGuards();
|
|
console.log('[tenant-isolation] OK: tenant-scoped runtime state guards passed');
|