SPB Git

Repository Public

api.test.ts 19.6 KB 253 lines typescript

1import { createHash } from 'node:crypto';2import { mkdtempSync } from 'node:fs';3import type { Server } from 'node:http';4import { tmpdir } from 'node:os';5import { join } from 'node:path';6import { createTestDb } from './test-db.ts';7import { afterAll, beforeAll, describe, expect, it } from 'vitest';8import { decodeFunctionData, erc20Abi, parseTransaction, recoverTransactionAddress } from 'viem';9import { NETWORKS } from '@spw/core';10import { TestPasskey } from '../../../tests/helpers/passkey.ts';11import { deriveKeys } from '../../isolated-signer/src/keys.ts';12import { createSignerServer } from '../../isolated-signer/src/server.ts';13import { Signer } from '../../isolated-signer/src/signer.ts';14import { Store } from '../../isolated-signer/src/store.ts';1516const ORIGIN = 'https://wallet.test', RP = 'wallet.test', SECRET = 'k'.repeat(48);17const keys = deriveKeys('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about');18const owner = new TestPasskey(RP, ORIGIN), attacker = new TestPasskey(RP, ORIGIN);19let signerSrv: Server, app: any, sql: any, adapters: any, cookie = '';20const swapSeen: any[] = [];21const USDC = NETWORKS.base.tokens[0]!.id, DEST = '0x70997970C51812dc3A010C7d01b50e0d17dc79C8';2223async function api(method: string, path: string, body?: unknown, opts: { cookie?: string | null; origin?: string | null; csrf?: boolean; idem?: string } = {}) {24  const headers: Record<string, string> = {};25  const c = opts.cookie === undefined ? cookie : opts.cookie;26  if (c) headers.cookie = c;27  if (method !== 'GET') { if (opts.origin !== null) headers.origin = opts.origin ?? ORIGIN; if (opts.csrf !== false) headers['x-spw-csrf'] = '1'; headers['content-type'] = 'application/json'; }28  if (opts.idem) headers['idempotency-key'] = opts.idem;29  const r = await app.inject({ method, url: path, headers, payload: body === undefined ? undefined : JSON.stringify(body) });30  return { status: r.statusCode, json: (() => { try { return r.json(); } catch { return null; } })(), setCookie: r.headers['set-cookie'] as string | undefined, headers: r.headers };31}3233beforeAll(async () => {34  const dir = mkdtempSync(join(tmpdir(), 'spw-api-'));35  const store = new Store(join(dir, 'state.json'), join(dir, 'policy.json'));36  store.state.setupTokenHash = createHash('sha256').update('the-setup-token').digest('hex');37  signerSrv = createSignerServer(new Signer(keys, store, { rpId: RP, origin: ORIGIN }), SECRET).listen(0, '127.0.0.1');38  await new Promise((r) => signerSrv.once('listening', r));39  Object.assign(process.env, { SPW_ORIGIN: ORIGIN, SPW_RP_ID: RP, DATABASE_URL: (await createTestDb('api')).url, SPW_SIGNER_SECRET: SECRET,40    SPW_SIGNER_URL: `http://127.0.0.1:${(signerSrv.address() as { port: number }).port}`, SPW_BROADCAST: 'off', SPW_WORKERS: 'off' });41  const db = await import('../src/db.ts');42  sql = db.sql;43  await sql.unsafe('drop schema public cascade; create schema public;');44  await db.migrate();45  adapters = (await import('../src/chains/index.ts')).adapters;46  // Deterministic chain data: the network is never contacted in this suite.47  adapters.base.prepare = async () => ({ section: { evm: { nonce: 7, gasLimit: '90000', maxFeePerGas: '1000000000', maxPriorityFeePerGas: '1000000' } }, maxFee: 90_000_000_000_000n, feeEstimate: 50_000_000_000_000n, warnings: [], ttlSec: 120 });48  // two scripted swap providers; the second answers for an attacker's address and must be dropped49  const fake = (id: string, bps: bigint, recipient?: string) => ({ id, name: id,50    capabilities: () => ({ networks: ['base'], crossChain: false, needsKey: false, configured: true, kinds: ['swap' as const], docs: '' }),51    supportsPair: (a: any, b: any) => a.network === 'base' && b.network === 'base',52    quote: async (r: any) => { swapSeen.push(r); const out = (r.amountIn * 2000n * (10_000n - bps)) / 10_000n / 10n ** 12n;53      return [{ id: `${id}-${swapSeen.length}`, provider: id, request: recipient ? { ...r, recipient } : r, steps: [{ kind: 'swap' as const, provider: id, from: r.from, to: r.to, via: ['pool'], contracts: [] }],54        amountOut: out, minAmountOut: (out * 995n) / 1000n, fees: [], estimatedSeconds: 12, priceImpactBps: 4, expiresAt: Date.now() + 30_000, firmness: 'indicative' as const,55        executability: { class: 'opaque' as const, reason: 'test' }, refundable: true, atomic: true }]; } });56  app = await (await import('../src/server.ts')).buildServer({ swapProviders: [fake('alpha', 10n), fake('beta', 30n), fake('evil', 0n, '0x000000000000000000000000000000000000dEaD')] });57});58afterAll(async () => { await app?.close(); signerSrv?.close(); await sql?.end(); });5960describe('access control', () => {61  it('everything is closed without a session; CSRF is enforced', async () => {62    for (const p of ['/api/v1/portfolio', '/api/v1/accounts', '/api/v1/activity', '/api/v1/security/overview', '/api/v1/security/audit', '/api/v1/settings', '/api/v1/health/ready'])63      expect((await api('GET', p, undefined, { cookie: null })).status).toBe(401);64    expect((await api('POST', '/api/v1/transfers/proposals', {}, { cookie: null })).status).toBe(401);65    expect((await api('POST', '/api/v1/security/emergency-halt', {}, { cookie: null })).status).toBe(401);66    expect((await api('POST', '/api/v1/auth/login/options', {}, { cookie: null, origin: 'https://evil.example' })).status).toBe(403);67    expect((await api('POST', '/api/v1/auth/login/options', {}, { cookie: null, csrf: false })).status).toBe(403);68    const live = await api('GET', '/api/v1/health/live', undefined, { cookie: null });69    expect(live.status).toBe(200);70    expect(String(live.headers['content-security-policy'])).toContain("script-src 'self'");71    expect((await api('POST', '/api/v1/sign', {}, { cookie: null })).status).toBe(401);72  });7374  it('setup needs the signer token, works once, then closes forever', async () => {75    expect((await api('GET', '/api/v1/auth/state', undefined, { cookie: null })).json).toEqual({ setupRequired: true, authenticated: false });76    let o = (await api('POST', '/api/v1/auth/setup/options', {}, { cookie: null })).json;77    const bad = await api('POST', '/api/v1/auth/setup/verify', { challengeId: o.challengeId, response: attacker.register(o.options.challenge), setupToken: 'wrong-token' }, { cookie: null });78    expect(bad.status).toBe(422);79    o = (await api('POST', '/api/v1/auth/setup/options', {}, { cookie: null })).json;80    const ok = await api('POST', '/api/v1/auth/setup/verify', { challengeId: o.challengeId, response: owner.register(o.options.challenge), setupToken: 'the-setup-token' }, { cookie: null });81    expect(ok.status).toBe(200);82    expect(String(ok.setCookie)).toMatch(/__Host-spw=.*HttpOnly; Secure; SameSite=Strict/);83    expect((await api('POST', '/api/v1/auth/setup/options', {}, { cookie: null })).status).toBe(403);84  });8586  it('only the enrolled passkey can log in', async () => {87    let o = (await api('POST', '/api/v1/auth/login/options', {}, { cookie: null })).json;88    const no = await api('POST', '/api/v1/auth/login/verify', { challengeId: o.challengeId, response: attacker.assert(Buffer.from(o.options.challenge, 'base64url')) }, { cookie: null });89    expect(no.status).toBe(401);90    o = (await api('POST', '/api/v1/auth/login/options', {}, { cookie: null })).json;91    const stale = owner.assert(Buffer.from(o.options.challenge, 'base64url'));92    const yes = await api('POST', '/api/v1/auth/login/verify', { challengeId: o.challengeId, response: stale }, { cookie: null });93    expect(yes.status).toBe(200);94    cookie = String(yes.setCookie).split(';')[0]!;95    expect((await api('POST', '/api/v1/auth/login/verify', { challengeId: o.challengeId, response: stale }, { cookie: null })).status).toBe(401); // challenge is single-use96    const acc = await api('GET', '/api/v1/accounts');97    expect(acc.status).toBe(200);98    expect(acc.json.accounts.find((a: any) => a.network === 'base').address).toBe(keys.addresses.evm);99    expect(JSON.stringify(acc.json)).not.toMatch(/abandon|priv/i);100  });101});102103describe('transfer lifecycle', () => {104  const draft = (amount = '12.5', idem = crypto.randomUUID()) => api('POST', '/api/v1/transfers/proposals', { network: 'base', asset: USDC, to: DEST, amount }, { idem });105106  it('validates input and applies policy before anything is drafted', async () => {107    expect((await api('POST', '/api/v1/transfers/proposals', { network: 'base', asset: USDC, to: DEST, amount: '1' })).status).toBe(400); // no idempotency key108    expect((await draft('0')).status).toBe(400);109    expect((await draft('1.0000001')).status).toBe(400);110    expect((await draft('50.000001')).json.error).toBe('policy');111    const badAddr = await api('POST', '/api/v1/transfers/proposals', { network: 'base', asset: USDC, to: 'HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk', amount: '1' }, { idem: crypto.randomUUID() });112    expect(badAddr.json.error).toBe('bad_address');113    const badAsset = await api('POST', '/api/v1/transfers/proposals', { network: 'base', asset: '0xdAC17F958D2ee523a2206206994597C13D831ec7', to: DEST, amount: '1' }, { idem: crypto.randomUUID() });114    expect(badAsset.json.error).toBe('unsupported_asset');115  });116117  it('is idempotent', async () => {118    const k = crypto.randomUUID();119    const a = await draft('3', k), b = await draft('3', k);120    expect(a.status).toBe(200);121    expect(b.json.id).toBe(a.json.id);122    expect(a.json.warnings).toContain('new_destination');123  });124125  it('a session alone cannot move funds; approval for another intent is refused by the signer', async () => {126    const d = (await draft('5')).json;127    const ra = (await api('POST', `/api/v1/transfers/proposals/${d.id}/request-approval`, {})).json;128    expect(ra.proposal.state).toBe('pending_approval');129    expect(Buffer.from(ra.options.challenge, 'base64url').toString('hex')).toBe(ra.proposal.intentHash);130    const none = await api('POST', `/api/v1/transfers/proposals/${d.id}/approve`, {});131    expect(none.status).toBe(400);132    expect((await api('GET', `/api/v1/transfers/proposals/${d.id}`)).json.state).toBe('pending_approval');133    expect((await api('GET', `/api/v1/transfers/proposals/${d.id}`)).json.txid).toBeNull();134135    const d2 = (await draft('5')).json;136    await api('POST', `/api/v1/transfers/proposals/${d2.id}/request-approval`, {});137    const wrong = await api('POST', `/api/v1/transfers/proposals/${d2.id}/approve`, { assertion: owner.assert(Buffer.from(ra.options.challenge, 'base64url')) });138    expect(wrong.status).toBe(422);139    const d3 = (await draft('5')).json;140    const ra3 = (await api('POST', `/api/v1/transfers/proposals/${d3.id}/request-approval`, {})).json;141    const rogue = await api('POST', `/api/v1/transfers/proposals/${d3.id}/approve`, { assertion: attacker.assert(Buffer.from(ra3.options.challenge, 'base64url')) });142    expect(rogue.status).toBe(422);143  });144145  it('signs exactly what was approved, and only once', async () => {146    const d = (await draft('12.5')).json;147    const ra = (await api('POST', `/api/v1/transfers/proposals/${d.id}/request-approval`, {})).json;148    const assertion = owner.assert(Buffer.from(ra.options.challenge, 'base64url'));149    const ok = await api('POST', `/api/v1/transfers/proposals/${d.id}/approve`, { assertion });150    expect(ok.status).toBe(200);151    expect(ok.json.state).toBe('broadcast');152    const [row] = await sql`select raw, txid from proposals where id = ${d.id}`;153    const tx = parseTransaction(row.raw);154    expect(await recoverTransactionAddress({ serializedTransaction: row.raw })).toBe(keys.addresses.evm);155    expect(tx.chainId).toBe(8453);156    expect(tx.nonce).toBe(7);157    expect(tx.to!.toLowerCase()).toBe(USDC.toLowerCase());158    expect(decodeFunctionData({ abi: erc20Abi, data: tx.data! }).args).toEqual([DEST, 12_500_000n]);159    expect((await api('POST', `/api/v1/transfers/proposals/${d.id}/approve`, { assertion })).status).toBe(409);160    expect(JSON.stringify(ok.json)).not.toContain(row.raw);161  });162163  it('refuses a second transfer on a network while the first is still unconfirmed', async () => {164    // the previous test left a proposal in "broadcast" (test mode never confirms it)165    const d = (await draft('1')).json;166    const blocked = await api('POST', `/api/v1/transfers/proposals/${d.id}/request-approval`, {});167    expect(blocked.status).toBe(409);168    expect(blocked.json.error).toBe('pending_tx');169    await sql`update proposals set state = 'finalized' where state = 'broadcast'`; // the chain confirmed it170    expect((await api('POST', `/api/v1/transfers/proposals/${d.id}/request-approval`, {})).status).toBe(200);171    await api('POST', `/api/v1/transfers/proposals/${d.id}/reject`, {});172  });173174  it('reject, tampered stored intent, daily limit and emergency halt all fail closed', async () => {175    const r = (await draft('1')).json;176    expect((await api('POST', `/api/v1/transfers/proposals/${r.id}/reject`, {})).json.state).toBe('rejected');177    expect((await api('POST', `/api/v1/transfers/proposals/${r.id}/request-approval`, {})).status).toBe(409);178179    // a compromised API/database swaps the destination after the owner approved the original180    const t = (await draft('2')).json;181    const ra = (await api('POST', `/api/v1/transfers/proposals/${t.id}/request-approval`, {})).json;182    await sql`update proposals set intent = jsonb_set(intent, '{to}', '"0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"') where id = ${t.id}`;183    const tampered = await api('POST', `/api/v1/transfers/proposals/${t.id}/approve`, { assertion: owner.assert(Buffer.from(ra.options.challenge, 'base64url')) });184    expect(tampered.status).toBe(422);185186    for (const amt of ['50', '37.5']) { // 12.5 already signed today → reaches the 100 USDC daily limit187      const d = (await draft(amt)).json;188      const a = (await api('POST', `/api/v1/transfers/proposals/${d.id}/request-approval`, {})).json;189      expect((await api('POST', `/api/v1/transfers/proposals/${d.id}/approve`, { assertion: owner.assert(Buffer.from(a.options.challenge, 'base64url')) })).status).toBe(200);190      await sql`update proposals set state = 'finalized' where state = 'broadcast'`;191    }192    expect((await draft('0.01')).json.error).toBe('policy');193194    expect((await api('POST', '/api/v1/security/emergency-halt', {})).status).toBe(200);195    expect((await api('GET', '/api/v1/security/overview')).json.signer.halted).toBe(true);196    expect((await draft('0.01')).json.error).toBe('policy');197  });198199  it('audit trail is hash-chained, append-only, and free of secrets', async () => {200    const a = (await api('GET', '/api/v1/security/audit?limit=500')).json;201    expect(a.intact).toBe(true);202    expect(a.total).toBeGreaterThan(15);203    await expect(sql`update audit_events set type = 'x' where seq = 1`).rejects.toThrow(/append-only/);204    await expect(sql`delete from audit_events`).rejects.toThrow(/append-only/);205    const dump = JSON.stringify(a) + JSON.stringify(await sql`select * from proposals`) + JSON.stringify(await sql`select * from sessions`);206    expect(dump).not.toMatch(/abandon/);207    expect(dump).not.toContain(Buffer.from(keys.evmPriv).toString('hex'));208    expect(dump).not.toContain(cookie.split('=')[1]);209  });210211  it('swap: quotes are for the wallet\'s own addresses, hostile answers are dropped, execution is gated', async () => {212    expect((await api('POST', '/api/v1/swap/quote', { from: 'base:native', to: `base:${USDC}`, amount: '0.01' }, { cookie: null })).status).toBe(401);213    expect((await api('GET', '/api/v1/swap/assets', undefined, { cookie: null })).status).toBe(401);214    const q = await api('POST', '/api/v1/swap/quote', { from: 'base:native', to: `base:${USDC}`, amount: '0.01', recipient: '0x000000000000000000000000000000000000dEaD' });215    expect(q.status).toBe(200);216    expect(swapSeen.every((r) => r.sender === keys.addresses.evm && r.recipient === keys.addresses.evm)).toBe(true); // the client cannot choose the recipient217    expect(q.json.routes.map((r: any) => r.providers[0]).sort()).toEqual(['alpha', 'beta']);218    expect(q.json.providers.find((p: any) => p.provider === 'evil').code).toBe('invalid_quote');219    const best = q.json.routes.find((r: any) => r.id === q.json.best.best_price);220    expect(best.providers).toEqual(['alpha']);221    expect(best.minAmountOut).toBe('19880100'); // 0.01 ETH × 2000 × 0.999 × 0.995, in USDC base units, as a string222    expect(best.minAmountOutDisplay).toBe('19.8801');223    for (const bad of [{ from: 'base:native', to: 'base:native', amount: '1' }, { from: 'base:native', to: 'base:0xdAC17F958D2ee523a2206206994597C13D831ec7', amount: '1' },224      { from: 'base:native', to: `base:${USDC}`, amount: '0' }, { from: 'base:native', to: `base:${USDC}`, amount: '1e3' }, { from: 'nochain:native', to: `base:${USDC}`, amount: '1' }])225      expect((await api('POST', '/api/v1/swap/quote', bad)).status, JSON.stringify(bad)).toBe(400);226    const far = await api('POST', '/api/v1/swap/quote', { from: 'bitcoin:native', to: 'solana:native', amount: '0.001' });227    expect(far.json.reachable).toBe(false); expect(far.json.routes).toEqual([]);228    const sse = await app.inject({ method: 'GET', url: `/api/v1/swap/quote/stream?from=base:native&to=base:${USDC}&amount=0.01`, headers: { cookie } });229    expect(sse.headers['content-type']).toContain('text/event-stream');230    const events = sse.body.split('\n\n').filter(Boolean).map((b: string) => b.split('\n')[0]!.replace('event: ', ''));231    expect(events[0]).toBe('start'); expect(events.filter((e: string) => e === 'route').length).toBe(2); expect(events[events.length - 1]).toBe('done');232    // execution: authenticated, idempotent, only on networks whose router has been reviewed, and refused while halted233    expect((await api('POST', '/api/v1/swap/proposals', { network: 'base', fromAsset: 'native', toAsset: USDC, amount: '0.01' }, { cookie: null, idem: crypto.randomUUID() })).status).toBe(401);234    expect((await api('POST', '/api/v1/swap/proposals', { network: 'base', fromAsset: 'native', toAsset: USDC, amount: '0.01' })).json.error).toBe('idempotency');235    expect((await api('POST', '/api/v1/swap/proposals', { network: 'bitcoin', fromAsset: 'native', toAsset: 'x', amount: '1' }, { idem: crypto.randomUUID() })).json.error).toBe('swap_not_enabled');236    expect((await api('POST', '/api/v1/swap/proposals', { network: 'solana', fromAsset: 'native', toAsset: 'x', amount: '1' }, { idem: crypto.randomUUID() })).json.error).toBe('unsupported_asset'); // Solana is served by Jupiter since V5 (swap-sol.test.ts)237    expect((await api('POST', '/api/v1/swap/proposals', { network: 'base', fromAsset: 'native', toAsset: '0xdAC17F958D2ee523a2206206994597C13D831ec7', amount: '1' }, { idem: crypto.randomUUID() })).json.error).toBe('unsupported_asset');238    expect((await api('POST', '/api/v1/swap/proposals', { network: 'base', fromAsset: 'native', toAsset: 'native', amount: '1' }, { idem: crypto.randomUUID() })).json.error).toBe('same_asset');239    expect((await api('POST', '/api/v1/swap/proposals', { network: 'base', fromAsset: 'native', toAsset: USDC, amount: '0.01', recipient: '0x000000000000000000000000000000000000dEaD' }, { idem: crypto.randomUUID() })).status).toBe(400); // no way to name a recipient240    expect((await api('POST', '/api/v1/swap/proposals', { network: 'base', fromAsset: 'native', toAsset: USDC, amount: '0.01' }, { idem: crypto.randomUUID() })).json.error).toBe('halted'); // the suite halted the signer earlier241    const st = (await api('GET', '/api/v1/swap/providers')).json;242    expect(st.providers.length).toBe(3);243    expect((await api('GET', '/api/v1/swap/execution')).json.networks).toEqual(['base', 'ethereum', 'arbitrum', 'polygon', 'optimism', 'bsc', 'avalanche', 'hyperevm']);244    const rows = await sql`select provider, ok, code from swap_quotes`;245    expect(rows.length).toBeGreaterThanOrEqual(4);246  });247248  it('logout and revoke-all end access', async () => {249    expect((await api('POST', '/api/v1/security/sessions/revoke-all', {})).status).toBe(200);250    expect((await api('GET', '/api/v1/portfolio')).status).toBe(401);251  });252});253
Files main
Menu

Theme