SPB Git

Repository Public

alerts-api.test.ts 26.3 KB 304 lines typescript

1// Alerts and monitoring over HTTP: owner session required everywhere, list / unread / mark read, live stream, settings,2// the whole life of a push subscription against a fake push service (a real local HTTP server that decrypts what it receives),3// the webhook secret, the error hook, and the dashboard aggregation on fixtures.4import { createServer, type Server } from 'node:http';5import { statSync } from 'node:fs';6import { afterAll, beforeAll, describe, expect, it } from 'vitest';7import type { AlertDispatcher } from '../src/alerts/dispatcher.ts';8import { decryptPush, generateSubscriptionKeys } from '../src/alerts/webpush.ts';9import { addAgent, addPaperAccount, addProposal, addVenueAccount, ownerCookie, world, type World } from './alerts-helpers.ts';1011const ORIGIN = 'https://wallet.test';12let w: World, sql: any, app: any, d: AlertDispatcher, cookie = '', push: Server, pushUrl = '';13const received: { path: string; headers: Record<string, unknown>; body: Buffer }[] = [];14const answer = new Map<string, number>();                            // per-path status of the fake push service15const browser = generateSubscriptionKeys();1617async function api(method: string, path: string, body?: unknown, o: { cookie?: string | null } = {}) {18  const headers: Record<string, string> = {};19  const c = o.cookie === undefined ? cookie : o.cookie;20  if (c) headers.cookie = c;21  if (method !== 'GET') { headers.origin = ORIGIN; headers['x-spw-csrf'] = '1'; headers['content-type'] = 'application/json'; }22  const r = await app.inject({ method, url: path, headers, payload: body === undefined ? undefined : JSON.stringify(body) });23  return { status: r.statusCode, json: (() => { try { return r.json(); } catch { return null; } })() };24}2526beforeAll(async () => {27  w = await world('alerts_api'); sql = w.sql;28  push = createServer((req, res) => {29    const chunks: Buffer[] = [];30    req.on('data', (c: Buffer) => chunks.push(c));31    req.on('end', () => { received.push({ path: req.url ?? '', headers: req.headers, body: Buffer.concat(chunks) }); res.writeHead(answer.get(req.url ?? '') ?? 201).end(); });32  }).listen(0, '127.0.0.1');33  await new Promise((r) => push.once('listening', r));34  const port = (push.address() as { port: number }).port;35  pushUrl = `http://127.0.0.1:${port}`;36  process.env.SPW_PUSH_ALLOW_HOSTS = `127.0.0.1:${port}`;             // production accepts only the browsers' push services37  d = w.dispatcher({ fetch, now: Date.now });38  w.mod.setAlerts(d);39  const { UserError } = await import('../src/config.ts');40  app = await (await import('../src/server.ts')).buildServer();41  app.get('/api/v1/__test/router', async () => { throw new UserError('router_changed', 'the swap router bytecode is not the reviewed one; swaps are refused', 503); });42  app.get('/api/v1/__test/other', async () => { throw new UserError('bad_amount', 'nope'); });43  await d.tail();44  cookie = await ownerCookie(sql);45});46afterAll(async () => { await app?.close(); push?.close(); await sql?.end(); });4748describe('access', () => {49  it('every alerts, push and monitoring endpoint needs the owner session; writes need the CSRF header', async () => {50    for (const p of ['/api/v1/alerts', '/api/v1/alerts/unread', '/api/v1/alerts/stream', '/api/v1/alerts/settings', '/api/v1/push/key', '/api/v1/push/subscriptions',51      '/api/v1/monitor/capital', '/api/v1/monitor/pnl', '/api/v1/monitor/exposure', '/api/v1/monitor/health'])52      expect((await api('GET', p, undefined, { cookie: null })).status, p).toBe(401);53    for (const [m, p] of [['POST', '/api/v1/alerts/read'], ['PUT', '/api/v1/alerts/settings'], ['POST', '/api/v1/alerts/webhook/secret'], ['POST', '/api/v1/alerts/webhook/test'],54      ['POST', '/api/v1/push/subscriptions'], ['POST', '/api/v1/push/test'], ['DELETE', '/api/v1/push/subscriptions/00000000-0000-4000-8000-000000000000']] as const)55      expect((await api(m, p, {}, { cookie: null })).status, p).toBe(401);56    const r = await app.inject({ method: 'POST', url: '/api/v1/alerts/read', headers: { cookie, 'content-type': 'application/json' }, payload: '{"all":true}' });57    expect(r.statusCode).toBe(403);58  });59});6061describe('in-app alerts', () => {62  it('list with filters, unread count, mark read', async () => {63    const agent = await addAgent(sql, { name: 'Momentum' });64    await d.raise({ kind: 'deposit.received', params: { network: 'Base', symbol: 'ETH', amount: '1.5' }, subject: { type: 'deposit', id: '0x1', network: 'base' } });65    await d.raise({ kind: 'agent.stopped_loss', params: { agent: 'Momentum', reason: 'loss limit reached' }, subject: { type: 'agent', id: agent } });66    await d.raise({ kind: 'conn.down', params: { name: 'Base', minutes: 3 } });67    const all = await api('GET', '/api/v1/alerts');68    expect(all.json).toMatchObject({ unread: 3, critical: 1 });69    expect(all.json.items.map((x: any) => x.kind)).toEqual(['conn.down', 'agent.stopped_loss', 'deposit.received']);70    expect(all.json.items[1]).toMatchObject({ severity: 'critical', group: 'agents', subject: { type: 'agent', id: agent }, readAt: null, params: { agent: 'Momentum', count: 1 } });71    expect((await api('GET', '/api/v1/alerts?severity=critical')).json.items).toHaveLength(1);72    expect((await api('GET', '/api/v1/alerts?group=system')).json.items.map((x: any) => x.kind)).toEqual(['conn.down']);73    expect((await api('GET', `/api/v1/alerts?limit=1&before=${all.json.items[0].id}`)).json.items[0].kind).toBe('agent.stopped_loss');74    expect((await api('GET', '/api/v1/alerts?severity=fatal')).status).toBe(400);75    const read = await api('POST', '/api/v1/alerts/read', { ids: [all.json.items[1].id] });76    expect(read.json).toMatchObject({ unread: 2, critical: 0 });77    expect((await api('GET', '/api/v1/alerts?unread=1')).json.items).toHaveLength(2);78    expect((await api('POST', '/api/v1/alerts/read', { all: true })).json.unread).toBe(0);79    expect((await api('POST', '/api/v1/alerts/read', { ids: [] })).status).toBe(400);80  });8182  it('a pinned-code refusal answered by any route becomes a critical alert, once per hour; ordinary errors do not', async () => {83    const before = (await api('GET', '/api/v1/alerts/unread')).json.unread;84    for (let i = 0; i < 3; i++) expect((await api('GET', '/api/v1/__test/router')).status).toBe(503);85    expect((await api('GET', '/api/v1/__test/other')).status).toBe(400);86    // v8: wait for the alert itself (the hook runs after the answer), not for a fixed 150 ms of real time87    let rows: any[] = [];88    for (let i = 0; i < 100 && rows.length === 0; i++) { rows = await w.alerts(`kind = 'security.pin_mismatch'`); if (!rows.length) await new Promise((r) => setTimeout(r, 20)); }89    await new Promise((r) => setTimeout(r, 30)); rows = await w.alerts(`kind = 'security.pin_mismatch'`); // …and still exactly one a moment later90    expect(rows).toHaveLength(1);91    expect(rows[0]).toMatchObject({ severity: 'critical', params: { what: '@router' } });92    expect((await api('GET', '/api/v1/alerts/unread')).json.unread).toBe(before + 1);93  });9495  it('the live stream sends the unread count at once and again when an alert arrives', async () => {96    await app.listen({ port: 0, host: '127.0.0.1' });97    const port = (app.server.address() as { port: number }).port;98    const ac = new AbortController();99    const r = await fetch(`http://127.0.0.1:${port}/api/v1/alerts/stream`, { headers: { cookie }, signal: ac.signal });100    expect(r.status).toBe(200);101    expect(r.headers.get('content-type')).toContain('text/event-stream');102    const reader = r.body!.getReader(), dec = new TextDecoder();103    let text = '';104    const until = async (re: RegExp) => { for (let i = 0; i < 50 && !re.test(text); i++) { const { value, done } = await reader.read(); if (done) break; text += dec.decode(value); } return re.exec(text); };105    const first = await until(/event: unread\ndata: (\{.*\})\n\n/);106    const n0 = JSON.parse(first![1]!).unread as number;107    text = '';108    await d.raise({ kind: 'deposit.received', params: { network: 'Base' } }); d.changed();109    const next = await until(/event: unread\ndata: (\{.*\})\n\n/);110    expect(JSON.parse(next![1]!).unread).toBe(n0 + 1);111    ac.abort();112    expect((await fetch(`http://127.0.0.1:${port}/api/v1/alerts/stream`)).status).toBe(401);113  });114});115116describe('settings', () => {117  it('defaults: every kind listed, criticals on, webhook off, details off, no secret anywhere in the answer', async () => {118    const r = await api('GET', '/api/v1/alerts/settings');119    expect(r.status).toBe(200);120    expect(r.json.settings).toMatchObject({ showDetails: false, capPerHour: 20, quiet: { enabled: false, tz: 'America/Toronto' }, webhook: { enabled: false, url: '', details: false } });121    expect(r.json.kinds.length).toBeGreaterThan(45);122    for (const k of r.json.kinds) if (k.severity === 'critical') expect(k.default, k.kind).toBe(true);123    for (const k of r.json.kinds) if (!k.fixed) expect(r.json.settings.rules[k.kind], k.kind).toBe(k.default);124    expect(r.json.settings.rules['system.test']).toBeUndefined();125    expect(r.json.webhook).toEqual({ secretSet: false });126    expect(JSON.stringify(r.json)).not.toMatch(/privateKey|secret"\s*:\s*"/);127  });128  it('switches, quiet hours and thresholds are validated; an unknown kind or a bad zone is refused', async () => {129    const ok = await api('PUT', '/api/v1/alerts/settings', { rules: { 'deposit.received': false }, quiet: { enabled: true, start: '23:00', end: '06:30' }, thresholds: { stuckMinutes: 45 }, showDetails: true });130    expect(ok.status).toBe(200);131    expect(ok.json.settings).toMatchObject({ rules: { 'deposit.received': false, 'transfer.confirmed': true }, quiet: { enabled: true, start: '23:00', end: '06:30', tz: 'America/Toronto' }, thresholds: { stuckMinutes: 45, staleBars: 3 }, showDetails: true });132    for (const bad of [{ rules: { 'nope.kind': true } }, { rules: { 'system.test': false } }, { quiet: { tz: 'Mars/Olympus' } }, { quiet: { start: '25:00' } }, { thresholds: { stuckMinutes: 1 } }, { capPerHour: 0 }, { extra: 1 },133      { webhook: { url: 'http://insecure.example/x' } }, { webhook: { enabled: true, url: 'https://ok.example/x' } }])134      expect((await api('PUT', '/api/v1/alerts/settings', bad)).status, JSON.stringify(bad)).toBe(400);135    const [last] = await sql`select type, data from audit_events where type = 'alerts.settings' order by seq desc limit 1`;136    expect(last.data).toMatchObject({ off: ['deposit.received'], on: [], quiet: true, webhook: null });137    await api('PUT', '/api/v1/alerts/settings', { rules: { 'deposit.received': true }, quiet: { enabled: false }, showDetails: false });138  });139  it('webhook: the secret is generated on the server, shown once, kept in a 0600 file; the test call is signed', async () => {140    const { verifyWebhook } = await import('../src/alerts/webhook.ts'), { secretsPath } = await import('../src/alerts/secrets.ts');141    const s = await api('POST', '/api/v1/alerts/webhook/secret', {});142    expect(s.json.secret).toMatch(/^[\w-]{43}$/);143    expect(statSync(secretsPath()).mode & 0o777).toBe(0o600);144    expect((await sql`select value from settings where key = 'alerts'`)[0]?.value?.webhook?.secret).toBeUndefined();   // never in the database145    answer.set('/hook', 200);146    // an http receiver is accepted only by the test dispatcher: the route itself insists on https147    expect((await api('PUT', '/api/v1/alerts/settings', { webhook: { enabled: true, url: `${pushUrl}/hook` } })).status).toBe(400);148    await w.store.saveAlertSettings({ ...(await w.store.alertSettings(true)), webhook: { enabled: true, url: `${pushUrl}/hook`, details: false } });149    received.length = 0;150    const t = await api('POST', '/api/v1/alerts/webhook/test', {});151    expect(t.json).toEqual({ ok: true, status: 200, error: null });152    const c = received[0]!;153    expect(verifyWebhook(s.json.secret, String(c.headers['x-spw-timestamp']), String(c.headers['x-spw-signature']), c.body.toString())).toBe(true);154    expect(JSON.parse(c.body.toString())).toMatchObject({ app: 'sp-wallet', kind: 'system.test', title: 'Test notification' });155    const view = await api('GET', '/api/v1/alerts/settings');156    expect(view.json.webhook).toEqual({ secretSet: true });157    expect(JSON.stringify(view.json)).not.toContain(s.json.secret);158    expect(view.json.webhookSample).toMatchObject({ title: 'Transfer confirmed', body: 'On Base.', params: { network: 'Base' } });159    await w.store.saveAlertSettings({ ...(await w.store.alertSettings(true)), webhook: { enabled: false, url: '', details: false } });160  });161});162163describe('web push subscriptions', () => {164  const sub = (path: string, extra: Record<string, unknown> = {}) => ({ endpoint: `${pushUrl}${path}`, keys: { p256dh: browser.p256dh, auth: browser.auth }, label: 'iPhone de test', locale: 'fr', ...extra });165  const decrypted = (path: string) => received.filter((r) => r.path === path).map((r) => JSON.parse(decryptPush(r.body, browser).toString()));166167  it('the VAPID key pair is generated on the server and only the public key is served; the file is 0600', async () => {168    const { secretsPath } = await import('../src/alerts/secrets.ts');169    const k = await api('GET', '/api/v1/push/key');170    expect(Buffer.from(k.json.publicKey, 'base64url')).toHaveLength(65);171    expect(Object.keys(k.json)).toEqual(['publicKey']);172    expect(statSync(secretsPath()).mode & 0o777).toBe(0o600);173    expect((await api('GET', '/api/v1/push/key')).json.publicKey).toBe(k.json.publicKey);   // stable across calls174  });175176  it('subscribe (per device, with a label), test notification, real alert, rename, expiry (410/404) and removal', async () => {177    expect((await api('POST', '/api/v1/push/test', {})).status).toBe(404);                // nothing subscribed: said, not an error 500178    await d.deliver();                                                                    // what was raised before any device existed stays in the app179    expect(received.filter((r) => r.path.startsWith('/push'))).toHaveLength(0);180    expect((await api('POST', '/api/v1/push/subscriptions', sub('/x', { endpoint: 'https://evil.example/push' }))).status).toBe(400);181    expect((await api('POST', '/api/v1/push/subscriptions', sub('/x', { endpoint: 'http://169.254.169.254/latest' }))).status).toBe(400);182    expect((await api('POST', '/api/v1/push/subscriptions', { ...sub('/x'), keys: { p256dh: 'short', auth: browser.auth } })).status).toBe(400);183    const a = await api('POST', '/api/v1/push/subscriptions', sub('/push/a'));184    expect(a.status).toBe(200);185    expect(a.json).toMatchObject({ label: 'iPhone de test', locale: 'fr', service: '127.0.0.1', failures: 0, lastOkAt: null });186    expect(JSON.stringify(a.json)).not.toContain(browser.auth);       // keys are write-only187    const again = await api('POST', '/api/v1/push/subscriptions', sub('/push/a', { label: '', locale: 'en' }));188    expect(again.json).toMatchObject({ id: a.json.id, label: 'iPhone de test', locale: 'en' });   // same device: updated, not duplicated189    const b = await api('POST', '/api/v1/push/subscriptions', sub('/push/b', { label: 'Mac' }));190    expect((await api('GET', '/api/v1/push/subscriptions')).json.map((s: any) => s.label)).toEqual(['iPhone de test', 'Mac']);191192    // the test button: the real path, a harmless payload, in the device's language193    received.length = 0;194    const t = await api('POST', '/api/v1/push/test', { id: a.json.id });195    expect(t.json.results).toEqual([{ id: a.json.id, ok: true, status: 201, removed: false, error: null }]);196    expect(received[0]!.headers).toMatchObject({ 'content-encoding': 'aes128gcm', ttl: '14400', urgency: 'normal' });197    expect(String(received[0]!.headers.authorization)).toMatch(/^vapid t=[\w-]+\.[\w-]+\.[\w-]+, k=[\w-]+$/);198    expect(decrypted('/push/a')[0]).toMatchObject({ v: 1, kind: 'system.test', l: 'en', d: false });199    expect(received.some((r) => r.path === '/push/b')).toBe(false);200201    // a real alert goes to both devices, each in its language, without the amount202    received.length = 0;203    await d.raise({ kind: 'deposit.received', params: { network: 'Base', symbol: 'ETH', amount: '1.5' }, subject: { type: 'deposit', id: '0x2', network: 'base' } });204    expect(await d.deliver()).toMatchObject({ sent: 1 });205    expect(decrypted('/push/a')[0]).toMatchObject({ kind: 'deposit.received', l: 'en', p: { network: 'Base' } });206    expect(decrypted('/push/b')[0]).toMatchObject({ kind: 'deposit.received', l: 'fr', p: { network: 'Base' } });207    expect(JSON.stringify((decrypted('/push/a')[0] as { p: unknown }).p)).not.toMatch(/1\.5|ETH/); // v8: the parameters only, not ids or timestamps208    expect((await api('GET', '/api/v1/push/subscriptions')).json[0].lastOkAt).toBeGreaterThan(0);209210    expect((await api('PATCH', `/api/v1/push/subscriptions/${b.json.id}`, { label: 'MacBook' })).json.label).toBe('MacBook');211    // device A's subscription expired at the push service: removed automatically; B answers 500 once: kept212    answer.set('/push/a', 410); answer.set('/push/b', 500);213    const t2 = await api('POST', '/api/v1/push/test', {});214    expect(t2.json.results.find((r: any) => r.id === a.json.id)).toMatchObject({ ok: false, removed: true, status: 410 });215    expect(t2.json.results.find((r: any) => r.id === b.json.id)).toMatchObject({ ok: false, removed: false, status: 500 });216    expect((await api('GET', '/api/v1/push/subscriptions')).json.map((s: any) => [s.label, s.failures])).toEqual([['MacBook', 1]]);217    answer.set('/push/b', 404);218    await d.raise({ kind: 'security.passkey_added', params: { label: 'k' } }); await d.deliver();219    expect((await api('GET', '/api/v1/push/subscriptions')).json).toEqual([]);220    // explicit removal221    answer.clear();222    const c = await api('POST', '/api/v1/push/subscriptions', sub('/push/c'));223    expect((await api('DELETE', `/api/v1/push/subscriptions/${c.json.id}`, {})).json).toEqual({ ok: true });224    expect((await api('GET', '/api/v1/push/subscriptions')).json).toEqual([]);225    expect((await sql`select count(*)::int as n from audit_events where type in ('alerts.push_subscribed', 'alerts.push_unsubscribed')`)[0].n).toBe(5);226  });227});228229describe('monitoring dashboard', () => {230  const H = 3_600_000;231  it('capital, PnL, exposure and health from fixtures — wallet, agents and derivatives kept apart', async () => {232    const { clearMonitorCache } = await import('../src/monitor/routes.ts');233    await sql`truncate deriv_accounts, deriv_paper_accounts, deriv_equity, deriv_fills, deriv_orders, proposals`; await sql`truncate agents cascade`;234    const now = Date.now(), at = (ms: number) => new Date(now - ms);235    const live = await addAgent(sql, { name: 'Live', state: 'LIVE', lastBar: at(H), mandateId: '00000000-0000-4000-8000-000000000001', mandate: { expiresAt: Math.floor((now + 200 * H) / 1000) }, capital: '500000000' });236    const paperAgent = await addAgent(sql, { name: 'Paper', state: 'PAPER_TRADING', lastBar: at(H) });237    await addAgent(sql, { name: 'New', state: 'CREATED' });238    for (const [id, mode, ago, eq, pos] of [[live, 'LIVE', 40 * 24 * H, 500, { USDC: 500 }], [live, 'LIVE', 8 * 24 * H, 520, { USDC: 520 }], [live, 'LIVE', 30 * H, 540, { USDC: 300, ETH: 0.08 }], [live, 'LIVE', 60_000, 561.5, { USDC: 300, ETH: 0.08 }],239      [paperAgent, 'PAPER', 3 * 24 * H, 1000, { USDC: 1000 }], [paperAgent, 'PAPER', 120_000, 950, { USDC: 400, BTC: 0.005, ETH: 0.02 }]] as const)240      await sql`insert into agent_equity (agent_id, ts, mode, equity_usd, positions) values (${id}, ${at(ago)}, ${mode}, ${eq}, ${sql.json(pos)})`;241    const paper = await addPaperAccount(sql, { positions: [{ instrument: 'hyperliquid:BTC', side: 'short', size: '0.01', markPrice: '100000' }, { instrument: 'dydx:ETH-USD', side: 'long', size: '1', markPrice: '3000' }] });242    const venue = await addVenueAccount(sql, { label: 'HL main', meta: { baseline: { 'hyperliquid:BTC': '0.002' }, baselineTs: now - 10 * 24 * H } }), testnet = await addVenueAccount(sql, { label: 'HL test', network: 'testnet' });243    for (const [id, ago, eq] of [[paper, 2 * 24 * H, 10000], [paper, 60_000, 10250.5], [venue, 9 * 24 * H, 2000], [venue, 26 * H, 2100], [venue, 60_000, 2080], [testnet, 60_000, 999]] as const)244      await sql`insert into deriv_equity (account_id, ts, equity, collateral, margin_used, unrealized_pnl) values (${id}, ${at(ago)}, ${eq}, ${eq}, 10, 0)`;245    await sql`insert into deriv_fills (account_id, fill_id, instrument, side, price, size, fee, fee_asset, ts) values (${venue}, 'f1', 'hyperliquid:BTC', 'sell', 100000, 0.012, 0, 'USDC', ${at(H)}), (${venue}, 'f0', 'hyperliquid:BTC', 'buy', 90000, 5, 0, 'USDC', ${at(20 * 24 * H)})`;246    await sql`insert into deriv_orders (account_id, client_id, instrument, side, type, size, reduce_only, tif, status, created_at, updated_at, state, final) values247      (${venue}, 'o1', 'hyperliquid:BTC', 'buy', 'limit', 1, false, 'gtc', 'open', now(), now(), 'open', false), (${venue}, 'o2', 'hyperliquid:BTC', 'buy', 'limit', 1, false, 'gtc', 'filled', now(), now(), 'filled', true)`;248    await sql`insert into deriv_orders (account_id, client_id, instrument, side, type, size, reduce_only, tif, status, created_at, updated_at) values (${paper}, 'p1', 'hyperliquid:BTC', 'buy', 'limit', 1, false, 'gtc', 'open', now(), now())`;249    for (const [s, c] of [['BTC', 100000], ['ETH', 3000]] as const) await sql`insert into candles (source, symbol, interval, ts, open, high, low, close, volume) values ('coinbase', ${s}, '1h', ${at(H)}, ${c}, ${c}, ${c}, ${c}, 1)`;250    await sql`insert into swap_quotes (search_id, provider, from_key, to_key, amount_in, ms, ok) values (${'00000000-0000-4000-8000-000000000002'}, 'kyberswap', 'a', 'b', 1, 120, true)`;251    await addProposal(sql, { kind: 'bridge', state: 'finalized', bridge: { phase: 'attesting' } });252    clearMonitorCache();253254    const cap = (await api('GET', '/api/v1/monitor/capital')).json;255    expect(cap.wallet).toEqual({ usd: null, cad: null, assets: 0, unpriced: 0, unavailable: 0 });   // the signer is not reachable in this suite: unknown, not zero256    expect(cap.totals).toEqual({ agentsLiveUsd: 561.5, agentsPaperUsd: 950, derivVenueUsd: 2080, derivPaperUsd: 10250.5, derivTestnetUsd: 999 });257    expect(Object.keys(cap.totals)).not.toContain('total');           // no "universal balance"258    expect(cap.agents.map((a: any) => [a.name, a.mode, a.real, a.equityUsd, a.capitalUsd, a.positions])).toEqual([['Live', 'LIVE', true, 561.5, 500, 1], ['Paper', 'PAPER', false, 950, 1000, 2], ['New', 'PAPER', false, null, 1000, 0]]);259    expect(cap.derivatives.map((a: any) => [a.label, a.kind, a.real, a.equity, a.positions, a.openOrders])).toEqual([['Paper HL', 'paper', false, 10250.5, 2, 1], ['HL main', 'venue', true, 2080, 1, 1], ['HL test', 'venue', false, 999, 0, 0]]);260    expect(cap.counts).toEqual({ agents: { LIVE: 1, PAPER_TRADING: 1, CREATED: 1 }, openPositions: { agents: 3, derivatives: 3 }, openOrders: { agents: 0, derivatives: 2 } });261262    const pnl = (await api('GET', '/api/v1/monitor/pnl')).json;263    const L = pnl.agents.find((a: any) => a.name === 'Live');264    expect(L.d7).toMatchObject({ abs: 41.5, pct: 7.98, partial: false });                 // 561.5 − 520 (the last snapshot before the window)265    expect(L.d30).toMatchObject({ abs: 61.5, pct: 12.3, partial: false });                // 561.5 − 500266    expect(L.today.abs === 21.5 || L.today.abs === 0).toBe(true);                         // depends on the hour of the run: base is the 30-h-old or the 1-min-old snapshot267    const P = pnl.agents.find((a: any) => a.name === 'Paper');268    expect(P.d7).toMatchObject({ abs: -50, pct: -5, partial: true });                     // the series is three days old: said269    expect(P.d30).toMatchObject({ abs: -50, partial: true });                             // the series starts inside the 30-day window: said270    expect(pnl.agents.find((a: any) => a.name === 'New')).toBeUndefined();271    const V = pnl.derivatives.find((a: any) => a.label === 'HL main');272    expect(V).toMatchObject({ kind: 'venue', real: true, equity: 2080, d7: { abs: 80, pct: 4, partial: false }, d30: { abs: 80, partial: true } });273    expect(pnl.derivatives.find((a: any) => a.label === 'Paper HL')).toMatchObject({ kind: 'paper', real: false, d7: { abs: 250.5, partial: true } });274    expect(pnl.note).toBe('flows_included');275276    const ex = (await api('GET', '/api/v1/monitor/exposure')).json;277    expect(ex.caveat).toBe('spot_and_perp_differ');278    // real: 0.08 ETH spot in the LIVE vault; venue account: baseline 0.002 − 0.012 sold = −0.01 BTC (the fill before the baseline is not counted)279    expect(ex.real).toEqual([{ asset: 'BTC', spotUsd: 0, perpLongUsd: 0, perpShortUsd: 1000, netUsd: -1000, grossUsd: 1000, priced: true }, { asset: 'ETH', spotUsd: 240, perpLongUsd: 0, perpShortUsd: 0, netUsd: 240, grossUsd: 240, priced: true }]);280    expect(ex.simulated).toEqual([{ asset: 'ETH', spotUsd: 60, perpLongUsd: 3000, perpShortUsd: 0, netUsd: 3060, grossUsd: 3060, priced: true }, { asset: 'BTC', spotUsd: 500, perpLongUsd: 0, perpShortUsd: 1000, netUsd: -500, grossUsd: 1500, priced: true }]);281282    const h = (await api('GET', '/api/v1/monitor/health')).json;283    const conn = (id: string) => h.connectors.find((c: any) => c.id === id);284    expect(conn('signer')).toMatchObject({ ok: false, detail: 'no answer' });285    expect(conn('database')).toMatchObject({ ok: true });286    expect(conn('rpc:base')).toMatchObject({ ok: null, detail: 'not checked yet' });        // workers are off here: unknown is not shown as healthy287    expect(conn('market:coinbase')).toMatchObject({ ok: true, group: 'market' });288    expect(conn('swap:kyberswap')).toMatchObject({ ok: true });289    expect(conn('bridge:circle')).toMatchObject({ ok: true, detail: '1 transfer(s) awaiting attestation' });290    expect(conn('venue:hyperliquid:mainnet:venue')).toMatchObject({ ok: true });291    expect(conn('venue:hyperliquid:mainnet:paper')).toMatchObject({ ok: true });292    expect(conn('venue:hyperliquid:testnet:venue')).toMatchObject({ ok: true });293    expect(h.audit).toMatchObject({ intact: true });294    expect(h.versions).toMatchObject({ signerKeyId: null, apiVersion: '1.0.0' });295    expect(h.disk.freePct).toBeGreaterThan(0);296    expect(h.signer).toEqual({ reachable: false, halted: null });297    // v8: `capabilities` names the VARIABLES that are missing (SPW_RESEARCH_RUNNER_SECRET is a name, not a secret): the rest of298    // the answer is checked as before, and the capabilities for values299    { const { capabilities: caps, ...rest } = h; expect(JSON.stringify(rest)).not.toMatch(/secret|privateKey|k{48}/i);300      expect(JSON.stringify(caps)).not.toMatch(/privateKey|k{48}|postgres:\/\//i); expect(caps.map((c: any) => c.id)).toContain('research_runner'); }301    expect(h.loops.every((l: any) => typeof l.name === 'string')).toBe(true); expect(h.loopsNote).toBeNull(); expect(h.lab).toBeDefined();302  });303});304
Files main
Menu

Theme