254 lines
10 KiB
TypeScript
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');
|