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 | |
| 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
| -rw-r--r-- | CHECKLIST-CVM-RELAY.md | 35 | ||||
| -rw-r--r-- | Makefile | 5 | ||||
| -rw-r--r-- | main/CMakeLists.txt | 7 | ||||
| -rw-r--r-- | main/cvm_server.c | 49 | ||||
| -rw-r--r-- | main/tollgate_main.c | 12 | ||||
| -rw-r--r-- | tests/integration/test-cvm-mcp-relay.mjs | 200 |
6 files changed, 278 insertions, 30 deletions
diff --git a/CHECKLIST-CVM-RELAY.md b/CHECKLIST-CVM-RELAY.md new file mode 100644 index 0000000..e7c512d --- /dev/null +++ b/CHECKLIST-CVM-RELAY.md | |||
| @@ -0,0 +1,35 @@ | |||
| 1 | # CVM Relay Stability Checklist | ||
| 2 | |||
| 3 | ## Pre-flight | ||
| 4 | - [x] Create worktree `/home/c03rad0r/esp32-tollgate-cvm-relay` | ||
| 5 | - [x] Create branch `feature/cvm-relay-stability` from master | ||
| 6 | - [x] Document plan in PLAN-CVM-RELAY.md | ||
| 7 | |||
| 8 | ## Task 3: Fix Relay Disconnect | ||
| 9 | - [x] Modify `cvm_relay_task()` inner loop: 1s TLS read timeout | ||
| 10 | - [x] Decouple ping timer from read success | ||
| 11 | - [x] Add consecutive-timeout counter for real disconnect detection | ||
| 12 | - [x] Handle relay close frames (opcode 0x08) | ||
| 13 | - [x] `make test-unit` passes (61/61) | ||
| 14 | - [x] Build firmware | ||
| 15 | - [x] Lock Board B: `make lock-b PHASE="cvm-relay-stability"` | ||
| 16 | - [x] Verify Board B port: `esptool.py --port /dev/ttyACM1 chip_id` | ||
| 17 | - [x] Flash Board B via `make flash-b` | ||
| 18 | - [x] Monitor serial: confirm WS connected, no disconnect in 120s | ||
| 19 | - [ ] Unlock Board B (deferred — may need more testing) | ||
| 20 | |||
| 21 | ## Task 1: Test get_sessions & get_usage via Relay | ||
| 22 | - [x] Write `tests/integration/test-cvm-mcp-relay.mjs` | ||
| 23 | - [x] Test: `get_sessions` returns JSON array via relay (0 active sessions) | ||
| 24 | - [x] Test: `get_usage` returns metric/price/step fields via relay | ||
| 25 | - [x] Both tests PASS on Board B (via `make test-cvm-mcp`) | ||
| 26 | |||
| 27 | ## Task 2: Test Non-Owner Auth Rejection via Relay | ||
| 28 | - [x] Test: non-owner request gets no response (12s wait) | ||
| 29 | - [x] Test: owner control request succeeds after non-owner test | ||
| 30 | - [x] Both tests PASS on Board B (via `make test-cvm-mcp`) | ||
| 31 | |||
| 32 | ## Final | ||
| 33 | - [x] `make test-unit` — 61 tests pass | ||
| 34 | - [x] Commit: 3 commits on feature/cvm-relay-stability | ||
| 35 | - [ ] Push to remote (nostr git relay down — will retry later) | ||
| @@ -280,6 +280,11 @@ test-cvm: | |||
| 280 | @echo "=== Running CVM integration test ===" | 280 | @echo "=== Running CVM integration test ===" |
| 281 | TOLLGATE_IP=$(TOLLGATE_IP) $(NODE) tests/integration/test-cvm.mjs | 281 | TOLLGATE_IP=$(TOLLGATE_IP) $(NODE) tests/integration/test-cvm.mjs |
| 282 | 282 | ||
| 283 | test-cvm-mcp: | ||
| 284 | $(call _require_board_lock) | ||
| 285 | @echo "=== Running CVM MCP relay integration test ===" | ||
| 286 | TOLLGATE_IP=$(TOLLGATE_IP) $(NODE) tests/integration/test-cvm-mcp-relay.mjs | ||
| 287 | |||
| 283 | # ────────────────────────────────────────────── | 288 | # ────────────────────────────────────────────── |
| 284 | # Wallet | 289 | # Wallet |
| 285 | # ────────────────────────────────────────────── | 290 | # ────────────────────────────────────────────── |
diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 6408e14..a041bc1 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt | |||
| @@ -16,13 +16,8 @@ idf_component_register(SRCS "tollgate_main.c" | |||
| 16 | "nip04.c" | 16 | "nip04.c" |
| 17 | "mcp_handler.c" | 17 | "mcp_handler.c" |
| 18 | "cvm_server.c" | 18 | "cvm_server.c" |
| 19 | "display.c" | ||
| 20 | "font.c" | ||
| 21 | "local_relay.c" | ||
| 22 | "relay_selector.c" | ||
| 23 | "sync_manager.c" | ||
| 24 | INCLUDE_DIRS "." | 19 | INCLUDE_DIRS "." |
| 25 | REQUIRES esp_wifi esp_event esp_netif nvs_flash esp_http_server | 20 | REQUIRES esp_wifi esp_event esp_netif nvs_flash esp_http_server |
| 26 | lwip json esp_http_client mbedtls esp-tls log spiffs | 21 | lwip json esp_http_client mbedtls esp-tls log spiffs |
| 27 | nucula_lib secp256k1 axs15231b qrcode wisp_relay | 22 | nucula_lib secp256k1 esp_timer |
| 28 | PRIV_REQUIRES esp-tls) | 23 | PRIV_REQUIRES esp-tls) |
diff --git a/main/cvm_server.c b/main/cvm_server.c index dd04047..644738b 100644 --- a/main/cvm_server.c +++ b/main/cvm_server.c | |||
| @@ -30,6 +30,9 @@ static void publish_announcements_via_ws(esp_tls_t *tls); | |||
| 30 | #define CVM_WS_BUF_SIZE 8192 | 30 | #define CVM_WS_BUF_SIZE 8192 |
| 31 | #define CVM_MAX_RESPONSE_SIZE 4096 | 31 | #define CVM_MAX_RESPONSE_SIZE 4096 |
| 32 | #define CVM_RECONNECT_DELAY_MS 5000 | 32 | #define CVM_RECONNECT_DELAY_MS 5000 |
| 33 | #define CVM_WS_READ_TIMEOUT_MS 1000 | ||
| 34 | #define CVM_WS_PING_INTERVAL_S 30 | ||
| 35 | #define CVM_WS_MAX_CONSECUTIVE_TIMEOUTS 65 | ||
| 33 | 36 | ||
| 34 | static char *parse_ws_text_frame(const uint8_t *buf, int len) | 37 | static char *parse_ws_text_frame(const uint8_t *buf, int len) |
| 35 | { | 38 | { |
| @@ -553,25 +556,47 @@ static void cvm_relay_task(void *arg) | |||
| 553 | return; | 556 | return; |
| 554 | } | 557 | } |
| 555 | 558 | ||
| 559 | int64_t last_ping_time = (int64_t)esp_timer_get_time() / 1000000; | ||
| 560 | int consecutive_timeouts = 0; | ||
| 561 | |||
| 556 | while (g_running) { | 562 | while (g_running) { |
| 557 | int rlen = esp_tls_conn_read(tls, buf, CVM_WS_BUF_SIZE - 1); | 563 | int rlen = esp_tls_conn_read(tls, buf, CVM_WS_BUF_SIZE - 1); |
| 558 | if (rlen < 0) { | 564 | if (rlen < 0) { |
| 559 | ESP_LOGW(TAG, "Read error on %s (rlen=%d)", relay_url, rlen); | 565 | consecutive_timeouts++; |
| 560 | break; | 566 | if (consecutive_timeouts >= CVM_WS_MAX_CONSECUTIVE_TIMEOUTS) { |
| 561 | } | 567 | ESP_LOGW(TAG, "Read timeout on %s (%d consecutive)", relay_url, consecutive_timeouts); |
| 562 | if (rlen == 0) { | 568 | break; |
| 569 | } | ||
| 570 | } else if (rlen == 0) { | ||
| 571 | ESP_LOGW(TAG, "Connection closed by %s", relay_url); | ||
| 563 | break; | 572 | break; |
| 564 | } | 573 | } else { |
| 565 | 574 | consecutive_timeouts = 0; | |
| 566 | if ((buf[0] & 0x0F) == 0x01) { | 575 | if ((buf[0] & 0x0F) == 0x01) { |
| 567 | char *text = parse_ws_text_frame(buf, rlen); | 576 | char *text = parse_ws_text_frame(buf, rlen); |
| 568 | if (text) { | 577 | if (text) { |
| 569 | if (strlen(text) > 0) { | 578 | if (strlen(text) > 0) { |
| 570 | process_relay_message(relay_url, text); | 579 | process_relay_message(tls, relay_url, text); |
| 580 | } | ||
| 581 | free(text); | ||
| 571 | } | 582 | } |
| 572 | free(text); | 583 | } else if ((buf[0] & 0x0F) == 0x09) { |
| 584 | ESP_LOGD(TAG, "Relay ping received, sending pong"); | ||
| 585 | uint8_t pong[2] = {0x8A, 0x00}; | ||
| 586 | esp_tls_conn_write(tls, pong, 2); | ||
| 587 | } else if ((buf[0] & 0x0F) == 0x08) { | ||
| 588 | ESP_LOGW(TAG, "Relay sent close frame"); | ||
| 589 | break; | ||
| 573 | } | 590 | } |
| 574 | } | 591 | } |
| 592 | |||
| 593 | int64_t now = (int64_t)esp_timer_get_time() / 1000000; | ||
| 594 | if (now - last_ping_time >= CVM_WS_PING_INTERVAL_S) { | ||
| 595 | uint8_t ping[2] = {0x89, 0x00}; | ||
| 596 | esp_tls_conn_write(tls, ping, 2); | ||
| 597 | last_ping_time = now; | ||
| 598 | ESP_LOGD(TAG, "Sent WS keepalive ping"); | ||
| 599 | } | ||
| 575 | } | 600 | } |
| 576 | 601 | ||
| 577 | free(buf); | 602 | free(buf); |
diff --git a/main/tollgate_main.c b/main/tollgate_main.c index 4741765..fa7a692 100644 --- a/main/tollgate_main.c +++ b/main/tollgate_main.c | |||
| @@ -23,10 +23,6 @@ | |||
| 23 | #include "tollgate_client.h" | 23 | #include "tollgate_client.h" |
| 24 | #include "lightning_payout.h" | 24 | #include "lightning_payout.h" |
| 25 | #include "cvm_server.h" | 25 | #include "cvm_server.h" |
| 26 | #include "display.h" | ||
| 27 | #include "local_relay.h" | ||
| 28 | #include "relay_selector.h" | ||
| 29 | #include "sync_manager.h" | ||
| 30 | 26 | ||
| 31 | #define MAX_STA_RETRY 5 | 27 | #define MAX_STA_RETRY 5 |
| 32 | static const char *TAG = "tollgate_main"; | 28 | static const char *TAG = "tollgate_main"; |
| @@ -182,11 +178,6 @@ static void start_services(void) | |||
| 182 | s_services_running = true; | 178 | s_services_running = true; |
| 183 | if (s_services_mutex) xSemaphoreGive(s_services_mutex); | 179 | if (s_services_mutex) xSemaphoreGive(s_services_mutex); |
| 184 | ESP_LOGI(TAG, "=== TollGate services started ==="); | 180 | ESP_LOGI(TAG, "=== TollGate services started ==="); |
| 185 | |||
| 186 | display_set_state(DISPLAY_READY); | ||
| 187 | char portal_url[128]; | ||
| 188 | snprintf(portal_url, sizeof(portal_url), "http://%s/", cfg->ap_ip_str); | ||
| 189 | display_update(cfg->ap_ssid, 0, 0, portal_url); | ||
| 190 | } | 181 | } |
| 191 | 182 | ||
| 192 | static void stop_services(void) | 183 | static void stop_services(void) |
| @@ -270,9 +261,6 @@ void app_main(void) | |||
| 270 | { | 261 | { |
| 271 | ESP_LOGI(TAG, "=== TollGate ESP32 Starting ==="); | 262 | ESP_LOGI(TAG, "=== TollGate ESP32 Starting ==="); |
| 272 | 263 | ||
| 273 | display_init(); | ||
| 274 | display_set_state(DISPLAY_BOOT); | ||
| 275 | |||
| 276 | esp_err_t ret = nvs_flash_init(); | 264 | esp_err_t ret = nvs_flash_init(); |
| 277 | if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) { | 265 | if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) { |
| 278 | ESP_ERROR_CHECK(nvs_flash_erase()); | 266 | ESP_ERROR_CHECK(nvs_flash_erase()); |
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 | }); | ||