diff options
| author | Your Name <you@example.com> | 2026-05-19 03:18:04 +0530 |
|---|---|---|
| committer | Your Name <you@example.com> | 2026-05-19 03:18:04 +0530 |
| commit | eeb9d2d1dfd38dd19fa641e6f733c917a3d1d005 (patch) | |
| tree | 4a3e3cf14290992a87968a833d7f041c45a7bf3b /tests/integration/test-cvm-mcp-relay.mjs | |
| parent | 81f2dc52dc42d01c89dff45a5407ec40b8863052 (diff) | |
feat: CVM relay stability fix + MCP relay integration tests
Relay disconnect fix (cvm_server.c):
- TLS read timeout reduced from 15s to 1s (short poll loop)
- Ping timer fires every 30s independently of read activity
- Consecutive timeout counter (65s) detects real disconnects
- Handle relay close frames (opcode 0x08) explicitly
- Result: 120s+ stable connection (previously ~37s disconnect cycle)
MCP relay integration tests (17/17 pass via make test-cvm-mcp):
- MCP initialize roundtrip via relay.primal.net
- get_sessions returns session array
- get_usage returns metric/price/step fields
- Non-owner auth rejection (board silently drops)
- Owner control request passes after rejection test
Build fixes:
- Remove display/font/axs15231b/qrcode deps (from display branch, not here)
- Remove local_relay/relay_selector/sync_manager deps (from relay branch)
- Add esp_timer to CMakeLists REQUIRES
Host unit tests: 61/61 pass
Diffstat (limited to 'tests/integration/test-cvm-mcp-relay.mjs')
| -rw-r--r-- | tests/integration/test-cvm-mcp-relay.mjs | 200 |
1 files changed, 200 insertions, 0 deletions
diff --git a/tests/integration/test-cvm-mcp-relay.mjs b/tests/integration/test-cvm-mcp-relay.mjs new file mode 100644 index 0000000..e5f42ba --- /dev/null +++ b/tests/integration/test-cvm-mcp-relay.mjs | |||
| @@ -0,0 +1,200 @@ | |||
| 1 | import { execSync } from 'child_process'; | ||
| 2 | |||
| 3 | const IP = process.env.TOLLGATE_IP || '10.192.45.1'; | ||
| 4 | const RELAY = 'wss://relay.primal.net'; | ||
| 5 | const OWNER_NSEC = 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2'; | ||
| 6 | |||
| 7 | let passed = 0, failed = 0; | ||
| 8 | |||
| 9 | function assert(condition, test) { | ||
| 10 | if (condition) { console.log(` \u2713 ${test}`); passed++; } | ||
| 11 | else { console.log(` \u2717 ${test}`); failed++; } | ||
| 12 | } | ||
| 13 | |||
| 14 | function nak(args, timeout = 15000) { | ||
| 15 | try { | ||
| 16 | return execSync(`timeout ${timeout / 1000} nak ${args}`, { | ||
| 17 | encoding: 'utf8', | ||
| 18 | stdio: ['pipe', 'pipe', 'pipe'], | ||
| 19 | timeout | ||
| 20 | }).trim(); | ||
| 21 | } catch (e) { | ||
| 22 | return e.stdout ? e.stdout.trim() : ''; | ||
| 23 | } | ||
| 24 | } | ||
| 25 | |||
| 26 | function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } | ||
| 27 | |||
| 28 | function generateId() { | ||
| 29 | return Math.floor(Math.random() * 100000); | ||
| 30 | } | ||
| 31 | |||
| 32 | async function sendMcpRequest(nsec, method, params, timeout = 15000) { | ||
| 33 | const id = generateId(); | ||
| 34 | const content = JSON.stringify({ jsonrpc: '2.0', id, method, params: params || {} }); | ||
| 35 | |||
| 36 | const boardNpub = nak(`key public ${OWNER_NSEC}`); | ||
| 37 | |||
| 38 | const result = nak( | ||
| 39 | `event -k 25910 -c '${content.replace(/'/g, "'\\''")}' -p ${boardNpub} --sec ${nsec} ${RELAY}`, | ||
| 40 | timeout | ||
| 41 | ); | ||
| 42 | |||
| 43 | const reqEventId = result.match(/"id":"([a-f0-9]{64})"/); | ||
| 44 | return { reqEventId: reqEventId ? reqEventId[1] : null, id, content }; | ||
| 45 | } | ||
| 46 | |||
| 47 | async function waitForResponse(reqEventId, timeout = 20000) { | ||
| 48 | const start = Date.now(); | ||
| 49 | while (Date.now() - start < timeout) { | ||
| 50 | const result = nak( | ||
| 51 | `req -k 25910 -l 5 --tag e=${reqEventId} ${RELAY}`, | ||
| 52 | 8000 | ||
| 53 | ); | ||
| 54 | if (result.length > 0 && result.includes('"id"')) { | ||
| 55 | return result; | ||
| 56 | } | ||
| 57 | await sleep(2000); | ||
| 58 | } | ||
| 59 | return null; | ||
| 60 | } | ||
| 61 | |||
| 62 | function extractMcpContent(rawEvent) { | ||
| 63 | try { | ||
| 64 | const lines = rawEvent.split('\n').filter(l => l.trim().startsWith('{')); | ||
| 65 | for (const line of lines) { | ||
| 66 | try { | ||
| 67 | const event = JSON.parse(line); | ||
| 68 | if (event.content) { | ||
| 69 | return JSON.parse(event.content); | ||
| 70 | } | ||
| 71 | } catch {} | ||
| 72 | } | ||
| 73 | } catch {} | ||
| 74 | return null; | ||
| 75 | } | ||
| 76 | |||
| 77 | async function runTests() { | ||
| 78 | console.log(`\n=== CVM MCP Relay Integration Tests ===`); | ||
| 79 | console.log(`Target: ${IP}`); | ||
| 80 | console.log(`Relay: ${RELAY}\n`); | ||
| 81 | |||
| 82 | const boardNpub = nak(`key public ${OWNER_NSEC}`); | ||
| 83 | console.log(`Board npub: ${boardNpub}`); | ||
| 84 | assert(boardNpub.length === 64, 'Board npub derived correctly'); | ||
| 85 | |||
| 86 | console.log('\n--- Pre-flight: Board is reachable ---'); | ||
| 87 | try { | ||
| 88 | const apiResult = execSync(`curl -s --connect-timeout 5 http://${IP}:2121/usage`, { encoding: 'utf8', timeout: 10000 }); | ||
| 89 | assert(apiResult.length > 0, 'API /usage responds (board online)'); | ||
| 90 | } catch (e) { | ||
| 91 | console.log(' WARNING: Board API not reachable. Tests may fail.'); | ||
| 92 | } | ||
| 93 | |||
| 94 | console.log('\n--- Test 1: MCP initialize via relay ---'); | ||
| 95 | { | ||
| 96 | const { reqEventId } = await sendMcpRequest(OWNER_NSEC, 'initialize'); | ||
| 97 | assert(reqEventId !== null, 'Initialize request published'); | ||
| 98 | if (reqEventId) { | ||
| 99 | const resp = await waitForResponse(reqEventId); | ||
| 100 | if (resp) { | ||
| 101 | assert(resp.includes('protocolVersion'), 'Response has protocolVersion'); | ||
| 102 | assert(resp.includes('TollGate'), 'Response has server name TollGate'); | ||
| 103 | } else { | ||
| 104 | assert(false, 'Got response for initialize'); | ||
| 105 | } | ||
| 106 | } | ||
| 107 | } | ||
| 108 | |||
| 109 | console.log('\n--- Test 2: get_sessions via relay ---'); | ||
| 110 | { | ||
| 111 | const { reqEventId } = await sendMcpRequest(OWNER_NSEC, 'tools/call', { name: 'get_sessions' }); | ||
| 112 | assert(reqEventId !== null, 'get_sessions request published'); | ||
| 113 | if (reqEventId) { | ||
| 114 | const resp = await waitForResponse(reqEventId); | ||
| 115 | if (resp) { | ||
| 116 | assert(resp.includes('content'), 'Response has content'); | ||
| 117 | const mcp = extractMcpContent(resp); | ||
| 118 | if (mcp && mcp.result && mcp.result.content) { | ||
| 119 | const text = mcp.result.content[0]?.text; | ||
| 120 | if (text) { | ||
| 121 | const sessions = JSON.parse(text); | ||
| 122 | assert(Array.isArray(sessions), 'get_sessions returns array'); | ||
| 123 | console.log(` Active sessions: ${sessions.filter(s => s.active).length}`); | ||
| 124 | } | ||
| 125 | } else { | ||
| 126 | assert(false, 'get_sessions MCP response parseable'); | ||
| 127 | } | ||
| 128 | } else { | ||
| 129 | assert(false, 'Got response for get_sessions'); | ||
| 130 | } | ||
| 131 | } | ||
| 132 | } | ||
| 133 | |||
| 134 | console.log('\n--- Test 3: get_usage via relay ---'); | ||
| 135 | { | ||
| 136 | const { reqEventId } = await sendMcpRequest(OWNER_NSEC, 'tools/call', { name: 'get_usage' }); | ||
| 137 | assert(reqEventId !== null, 'get_usage request published'); | ||
| 138 | if (reqEventId) { | ||
| 139 | const resp = await waitForResponse(reqEventId); | ||
| 140 | if (resp) { | ||
| 141 | assert(resp.includes('content'), 'Response has content'); | ||
| 142 | const mcp = extractMcpContent(resp); | ||
| 143 | if (mcp && mcp.result && mcp.result.content) { | ||
| 144 | const text = mcp.result.content[0]?.text; | ||
| 145 | if (text) { | ||
| 146 | const usage = JSON.parse(text); | ||
| 147 | assert(usage.metric !== undefined, 'get_usage returns metric'); | ||
| 148 | assert(usage.price_per_step !== undefined, 'get_usage returns price_per_step'); | ||
| 149 | assert(usage.step_size_ms !== undefined, 'get_usage returns step_size_ms'); | ||
| 150 | console.log(` metric=${usage.metric}, price=${usage.price_per_step}, step=${usage.step_size_ms}ms`); | ||
| 151 | } | ||
| 152 | } else { | ||
| 153 | assert(false, 'get_usage MCP response parseable'); | ||
| 154 | } | ||
| 155 | } else { | ||
| 156 | assert(false, 'Got response for get_usage'); | ||
| 157 | } | ||
| 158 | } | ||
| 159 | } | ||
| 160 | |||
| 161 | console.log('\n--- Test 4: Non-owner auth rejection ---'); | ||
| 162 | { | ||
| 163 | const throwawayNsec = nak('key generate'); | ||
| 164 | const throwawayNpub = nak(`key public ${throwawayNsec}`); | ||
| 165 | console.log(` Throwaway npub: ${throwawayNpub.substring(0, 16)}...`); | ||
| 166 | assert(throwawayNsec.length === 64, 'Generated throwaway nsec'); | ||
| 167 | |||
| 168 | const { reqEventId } = await sendMcpRequest(throwawayNsec, 'tools/call', { name: 'get_config' }); | ||
| 169 | assert(reqEventId !== null, 'Non-owner request published'); | ||
| 170 | if (reqEventId) { | ||
| 171 | const resp = await waitForResponse(reqEventId, 12000); | ||
| 172 | if (resp && resp.includes('"id"')) { | ||
| 173 | assert(false, 'Non-owner should NOT get a response'); | ||
| 174 | } else { | ||
| 175 | assert(true, 'Non-owner correctly ignored (no response)'); | ||
| 176 | } | ||
| 177 | } | ||
| 178 | |||
| 179 | await sleep(2000); | ||
| 180 | |||
| 181 | console.log(' Control: sending owner request to verify board still responsive...'); | ||
| 182 | const { reqEventId: ctrlId } = await sendMcpRequest(OWNER_NSEC, 'tools/call', { name: 'get_config' }); | ||
| 183 | if (ctrlId) { | ||
| 184 | const ctrlResp = await waitForResponse(ctrlId); | ||
| 185 | if (ctrlResp) { | ||
| 186 | assert(true, 'Owner control request after rejection test: PASS'); | ||
| 187 | } else { | ||
| 188 | assert(false, 'Owner control request should get response'); | ||
| 189 | } | ||
| 190 | } | ||
| 191 | } | ||
| 192 | |||
| 193 | console.log(`\n=== Results: ${passed} passed, ${failed} failed ===\n`); | ||
| 194 | process.exit(failed > 0 ? 1 : 0); | ||
| 195 | } | ||
| 196 | |||
| 197 | runTests().catch(e => { | ||
| 198 | console.error('Test error:', e.message); | ||
| 199 | process.exit(1); | ||
| 200 | }); | ||