learning_ai_invt_trdg/backend/verifyTenantIsolation.ts

254 lines
10 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';
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 originalVerify = supabaseService.verifyAccessToken.bind(supabaseService);
const mockedVerify = async (token: string): Promise<{ userId: string | null; error?: string }> => {
if (token === 'token-user-a') return { userId: 'user-a' };
if (token === 'token-user-b') return { userId: 'user-b' };
return { userId: null, error: 'invalid token' };
};
(supabaseService as any).verifyAccessToken = mockedVerify;
const originalLoadSnapshot = supabaseService.loadLatestBotStateSnapshot.bind(supabaseService);
(supabaseService as any).loadLatestBotStateSnapshot = async () => null;
const port = 6200 + Math.floor(Math.random() * 400);
const server = new ApiServer(port);
try {
await new Promise((resolve) => setTimeout(resolve, 250));
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 readStatus = async (token: string): Promise<any> => {
const response = await fetch(`http://127.0.0.1:${port}/api/state`, {
headers: { Authorization: `Bearer ${token}` }
});
assert.equal(response.status, 200, `Expected /api/state 200 for token ${token}`);
return await response.json();
};
const userAState = await readStatus('token-user-a');
const userBState = await readStatus('token-user-b');
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 symbolResponseA = await fetch(`http://127.0.0.1:${port}/api/symbol/BTC%2FUSD`, {
headers: { Authorization: 'Bearer token-user-a' }
});
assert.equal(symbolResponseA.status, 200, 'User A symbol endpoint should be reachable.');
const symbolStateA = await symbolResponseA.json();
assert.deepEqual(Object.keys(symbolStateA.profileSignals || {}), ['profile-a'], 'Symbol endpoint leaked foreign profile signal to User A.');
const symbolResponseB = await fetch(`http://127.0.0.1:${port}/api/symbol/BTC%2FUSD`, {
headers: { Authorization: 'Bearer token-user-b' }
});
assert.equal(symbolResponseB.status, 200, 'User B symbol endpoint should be reachable.');
const symbolStateB = await symbolResponseB.json();
assert.deepEqual(Object.keys(symbolStateB.profileSignals || {}), ['profile-b'], 'Symbol endpoint leaked foreign profile signal to User B.');
} finally {
await server.stop();
(supabaseService as any).verifyAccessToken = originalVerify;
(supabaseService as any).loadLatestBotStateSnapshot = originalLoadSnapshot;
}
}
await verifyStaticIsolationGuards();
await verifyRuntimeIsolationGuards();
console.log('[tenant-isolation] OK: tenant-scoped runtime state guards passed');