diff options
| -rw-r--r-- | AGENTS.md | 4 | ||||
| -rw-r--r-- | CHECKLIST.md | 191 | ||||
| -rw-r--r-- | Makefile | 91 | ||||
| -rw-r--r-- | package.json | 20 | ||||
| -rw-r--r-- | tests/e2e/captive-portal.spec.mjs (renamed from tests/captive-portal.spec.mjs) | 2 | ||||
| -rw-r--r-- | tests/e2e/interop-happy-path.spec.mjs (renamed from tests/interop-happy-path.spec.mjs) | 0 | ||||
| -rw-r--r-- | tests/e2e/playwright.config.mjs (renamed from tests/playwright.config.mjs) | 2 | ||||
| -rw-r--r-- | tests/helpers/network.mjs | 2 | ||||
| -rw-r--r-- | tests/integration/api.mjs (renamed from tests/api.mjs) | 0 | ||||
| -rw-r--r-- | tests/integration/network.mjs (renamed from tests/network.mjs) | 2 | ||||
| -rw-r--r-- | tests/integration/phase2.mjs (renamed from tests/phase2.mjs) | 2 | ||||
| -rw-r--r-- | tests/integration/smoke.mjs (renamed from tests/smoke.mjs) | 2 | ||||
| -rw-r--r-- | tests/integration/test-dns-firewall.mjs | 123 | ||||
| -rw-r--r-- | tests/integration/test-reset-auth.mjs | 101 | ||||
| -rw-r--r-- | tests/integration/test-session-expiry.mjs | 103 |
15 files changed, 479 insertions, 166 deletions
| @@ -51,8 +51,8 @@ nvs_flash_init() | |||
| 51 | - `wifistr.c/h` — kind 38787 event builder + WebSocket relay publish | 51 | - `wifistr.c/h` — kind 38787 event builder + WebSocket relay publish |
| 52 | - `captive_portal.c/h` — HTTP :80 portal, captive detection, grant/reset | 52 | - `captive_portal.c/h` — HTTP :80 portal, captive detection, grant/reset |
| 53 | - `dns_server.c/h` — DNS hijack/forward per-client, DoT reject | 53 | - `dns_server.c/h` — DNS hijack/forward per-client, DoT reject |
| 54 | - `firewall.c/h` — NAPT on/off per-client, MAC resolution | 54 | - `firewall.c/h` — per-client NAT filter via LWIP_HOOK_IP4_CANFORWARD, MAC resolution |
| 55 | - `session.c/h` — time-based sessions, spent-secret tracking | 55 | - `session.c/h` — time-based sessions, MAC tracking |
| 56 | - `cashu.c/h` — Cashu token decode, checkstate, allotment calc | 56 | - `cashu.c/h` — Cashu token decode, checkstate, allotment calc |
| 57 | - `tollgate_api.c/h` — HTTP :2121, payment endpoints, wallet endpoints | 57 | - `tollgate_api.c/h` — HTTP :2121, payment endpoints, wallet endpoints |
| 58 | 58 | ||
diff --git a/CHECKLIST.md b/CHECKLIST.md index b71bd14..c5dfbe4 100644 --- a/CHECKLIST.md +++ b/CHECKLIST.md | |||
| @@ -22,158 +22,115 @@ | |||
| 22 | - [x] Tests 1-14: ALL PASSING | 22 | - [x] Tests 1-14: ALL PASSING |
| 23 | 23 | ||
| 24 | ## Phase 2: E-Cash Payments — COMPLETE | 24 | ## Phase 2: E-Cash Payments — COMPLETE |
| 25 | ### Code Written | 25 | - [x] Implement cashu.c/h, session.c/h, tollgate_api.c/h |
| 26 | - [x] Implement cashu.c/h (Cashu token parse, base64url, checkstate, mint validation) | 26 | - [x] Update captive portal HTML with payment form |
| 27 | - [x] Implement session.c/h (time-based allotment, expiry, secret tracking, MAC tracking) | 27 | - [x] Wire into tollgate_main.c |
| 28 | - [x] Implement tollgate_api.c/h (:2121 server, GET/POST /, /usage, /whoami) | 28 | - [x] Per-MAC access tracking, two httpd instances |
| 29 | - [x] Update captive portal HTML with payment form (Cashu token textarea + "Pay & Connect") | 29 | - [x] Bug fixes: stack overflow, heap allocations, TLS, token decode |
| 30 | - [x] Wire into tollgate_main.c (session_init, api_start, session_tick loop) | ||
| 31 | - [x] Per-MAC access tracking: `firewall_get_mac_for_ip()` using `esp_wifi_ap_get_sta_list_with_ip()` + ARP fallback | ||
| 32 | - [x] Two httpd instances: port 80 (captive portal) and port 2121 (TollGate API) | ||
| 33 | |||
| 34 | ### Bug Fixes | ||
| 35 | - [x] Stack overflow: httpd stack_size increased to 32768 (TLS+mbedTLS needs ~20KB) | ||
| 36 | - [x] Heap allocations: cashu_token_t, cashu_proof_state_t, json_buf, post_body all heap-allocated | ||
| 37 | - [x] TLS to mint: `esp_crt_bundle_attach` + `esp-tls` in CMakeLists.txt REQUIRES | ||
| 38 | - [x] HTTP client: `open/write/fetch_headers/read` pattern (not `perform`) | ||
| 39 | - [x] Token decode: dynamic `json_buf` sizing `malloc((b64_len * 3) / 4 + 4)`, strip trailing `\n`/`\r` | ||
| 40 | - [x] POST body recv: loop `httpd_req_recv` until all `content_len` bytes read | ||
| 41 | - [x] `secret_count` bug: capped at `MIN(proof_count, 5)` before `session_create` | ||
| 42 | - [x] `config.c` default mint URL fixed to `testnut.cashu.space` | ||
| 43 | - [x] Makefile: nutshell wallet targets (wallet-setup, wallet-info, mint-token, send-token) | ||
| 44 | - [x] `tests/phase2.mjs`: `/whoami` test checks `includes('mac=')` | ||
| 45 | |||
| 46 | ### Tests Passing | ||
| 47 | - [x] Tests 15-24: ALL PASSING | 30 | - [x] Tests 15-24: ALL PASSING |
| 48 | 31 | ||
| 49 | ## Phase 3: On-Device Wallet + Nostr Identity + Wifistr — COMPLETE | 32 | ## Phase 3: On-Device Wallet + Nostr Identity + Wifistr — COMPLETE |
| 50 | ### nucula Wallet Integration | 33 | - [x] nucula wallet integration (git submodule, C++ bridge, C API) |
| 51 | - [x] Add nucula as git submodule (`nucula_src/`) | 34 | - [x] Nostr identity derivation (HMAC-SHA512, MAC/SSID/IP) |
| 52 | - [x] Create `components/secp256k1/` (symlink to nucula's libsecp256k1) | 35 | - [x] Nostr event signing (NIP-01, Schnorr) |
| 53 | - [x] Create `components/nucula_lib/` (C++ bridge + C API) | 36 | - [x] Geohash encoding |
| 54 | - [x] C bridge: `nucula_wallet.h` (init, receive, send, swap_all, balance, proofs_json) | 37 | - [x] Wifistr service discovery (kind 38787) |
| 55 | - [x] All wallet operations tested on Board A: pay, swap, send, persistence | 38 | - [x] 58 unit tests passing |
| 56 | |||
| 57 | ### Nostr Identity Derivation (identity.c/h) | ||
| 58 | - [x] HMAC-SHA512 derivation via mbedtls, npub via secp256k1 | ||
| 59 | - [x] Derive STA/AP MAC, SSID, AP IP from nsec | ||
| 60 | - [x] Set MACs via `esp_wifi_set_mac()` in boot sequence | ||
| 61 | - [x] 24/24 unit tests passing | ||
| 62 | |||
| 63 | ### Nostr Event Signing (nostr_event.c/h) | ||
| 64 | - [x] NIP-01 canonical JSON, SHA-256 ID, Schnorr signature | ||
| 65 | - [x] 23/23 unit tests passing | ||
| 66 | |||
| 67 | ### Geohash Encoding (geohash.c/h) | ||
| 68 | - [x] Standard base-32 geohash encoding | ||
| 69 | - [x] 11/11 unit tests passing | ||
| 70 | |||
| 71 | ### Wifistr Service Discovery (wifistr.c/h) | ||
| 72 | - [x] kind 38787 event builder + WebSocket relay publish | ||
| 73 | - [x] Publish on boot + periodic timer (6h default) | ||
| 74 | - [x] Verified published to relay.damus.io and nos.lol | ||
| 75 | 39 | ||
| 76 | ## Phase 4: ESP32 TollGate Client Detection + Auto-Payment — COMPLETE (commit `78dd599`) | 40 | ## Phase 4: ESP32 TollGate Client Detection + Auto-Payment — COMPLETE (commit `78dd599`) |
| 77 | - [x] tollgate_client.c/h — detection, payment, monitoring, state machine | 41 | - [x] tollgate_client.c/h — detection, payment, monitoring, state machine |
| 78 | - [x] Config fields: client_enabled, client_steps_to_buy, etc. | ||
| 79 | - [x] Integration into tollgate_main.c | ||
| 80 | - [x] 30/30 unit tests passing | 42 | - [x] 30/30 unit tests passing |
| 81 | 43 | ||
| 82 | ## Phase 5: Lightning Auto-Payout — COMPLETE (commit `cb4bd7d`) | 44 | ## Phase 5: Lightning Auto-Payout — COMPLETE (commit `cb4bd7d`) |
| 83 | - [x] lnurl_pay.c/h — LNURL-pay HTTP flow | 45 | - [x] lnurl_pay.c/h, lightning_payout.c/h, nucula_wallet_melt() |
| 84 | - [x] lightning_payout.c/h — periodic balance check, threshold, multi-recipient split, melt | 46 | - [x] 18 unit tests passing |
| 85 | - [x] nucula_wallet_melt() bridge for NUT-05 | ||
| 86 | - [x] Config: payout.enabled, recipients, mints, fee_tolerance, etc. | ||
| 87 | - [x] 7/7 lnurl_pay + 11/11 lightning_payout = 18 unit tests passing | ||
| 88 | 47 | ||
| 89 | ## Phase 6: Bytes-Based Billing — COMPLETE (commit `edd125d`) | 48 | ## Phase 6: Bytes-Based Billing — COMPLETE (commit `edd125d`) |
| 90 | - [x] Dual-metric session support (milliseconds + bytes) | 49 | - [x] Dual-metric session support (milliseconds + bytes) |
| 91 | - [x] session_create_bytes(), session_add_bytes() | ||
| 92 | - [x] Config: metric, step_size_bytes | ||
| 93 | - [x] Discovery endpoint advertises correct metric | ||
| 94 | - [x] Unit tests: bytes session lifecycle, mixed metrics | ||
| 95 | 50 | ||
| 96 | ## Phase 7: MCP Handler + NIP-04 + CVM Server — COMPLETE (commit `fdf662f`) | 51 | ## Phase 7: MCP Handler + NIP-04 + CVM Server — COMPLETE (commit `fdf662f`) |
| 97 | - [x] mcp_handler.c/h — 4 tools (get_config, set_config, get_balance, wallet_send), 25 unit tests | 52 | - [x] mcp_handler.c/h (4 tools, 25 unit tests) |
| 98 | - [x] nip04.c/h — AES-256-CBC + ECDH with 0x02 compressed pubkey prefix, 15 unit tests | 53 | - [x] nip04.c/h (AES-256-CBC + ECDH, 15 unit tests) |
| 99 | - [x] cvm_server.c/h — Nostr DM listener skeleton with FreeRTOS task | 54 | - [x] cvm_server.c/h (Nostr DM listener) |
| 100 | - [x] Fixed NIP-04 IV bug: mbedtls_aes_crypt_cbc modifies IV in-place | ||
| 101 | - [x] Fixed missing esp_random.h include in nip04.c | ||
| 102 | - [x] 156 total unit tests passing across 10 test binaries | ||
| 103 | 55 | ||
| 104 | ## Bug Fixes — COMPLETE (commit `3342c8e`) | 56 | ## Bug Fixes — COMPLETE (commit `3342c8e`) |
| 105 | - [x] reset_auth_handler now calls session_revoke_all() before firewall_revoke_all() | 57 | - [x] reset_auth, /usage, metric default, sys_evt stack overflow fixes |
| 106 | - [x] Port 80 /usage shows real session data (remaining/total) instead of "0/0" | ||
| 107 | - [x] Config metric defaults to "milliseconds" (ESP32 can't track per-client bytes from NAT) | ||
| 108 | - [x] Fixed sys_evt stack overflow: deferred start_services() to dedicated 32KB task | ||
| 109 | 58 | ||
| 110 | ## Playwright Interop Tests — COMPLETE (commit `4fb44e7`) | 59 | ## Playwright Interop Tests — COMPLETE (commit `4fb44e7`) |
| 111 | - [x] 18/18 tests passing (11 ESP32 + 7 ESP32↔OpenWRT interop) | 60 | - [x] 18/18 tests passing (11 ESP32 + 7 ESP32↔OpenWRT interop) |
| 112 | - [x] 7 screenshots generated | 61 | |
| 113 | - [x] Double-spend rejection verified on live hardware | 62 | ## Per-Client NAT Filtering — COMPLETE (commit `0c2c67b`) |
| 63 | - [x] Create `main/lwip_tollgate_hooks.h` — LWIP_HOOK_IP4_CANFORWARD definition | ||
| 64 | - [x] Update `CMakeLists.txt` — inject hook header into lwIP compilation | ||
| 65 | - [x] Add `tollgate_ip4_canforward_filter()` to `firewall.c` — filter by source IP, network byte order | ||
| 66 | - [x] NAT always ON, per-client filter in lwIP forwarding path | ||
| 67 | - [x] Remove `update_nat()`, `firewall_enable_nat()`, `firewall_disable_nat()` | ||
| 68 | - [x] Subnet-aware: only filter AP subnet packets, allow internet responses | ||
| 69 | - [x] Fix byte order bug: firewall stores IPs in network byte order | ||
| 70 | - [x] Reduce API server stack 32KB→16KB (fixes ESP_ERR_HTTPD_TASK) | ||
| 71 | - [x] E2E verified: block→pay→allow→revoke→block on live hardware | ||
| 72 | |||
| 73 | ## Spent-Secret Cleanup — COMPLETE (commit `0c2c67b`) | ||
| 74 | - [x] Remove `s_spent_secrets[]`, `session_is_secret_spent()` from session.c | ||
| 75 | - [x] Remove `spent_secrets`/`spent_secret_count` from `session_t` | ||
| 76 | - [x] Remove spent-secret params from `session_create()`/`session_create_bytes()` | ||
| 77 | - [x] Remove local spent-secret check in `tollgate_api.c` | ||
| 78 | - [x] Update `tests/unit/test_session.c` | ||
| 79 | - [x] 186 unit tests passing | ||
| 114 | 80 | ||
| 115 | --- | 81 | --- |
| 116 | 82 | ||
| 117 | ## TODO — In Progress | 83 | ## TODO — Remaining |
| 118 | |||
| 119 | ### Per-Client NAT Filtering (Multi-Client Fix) | ||
| 120 | - [ ] Create `main/lwip_tollgate_hooks.h` — LWIP_HOOK_IP4_CANFORWARD definition | ||
| 121 | - [ ] Update `CMakeLists.txt` — inject hook header into lwIP compilation | ||
| 122 | - [ ] Add `tollgate_ip4_canforward_filter()` to `firewall.c` — filter forwarded packets by source IP | ||
| 123 | - [ ] Change firewall strategy: NAT always ON, per-client filter in lwIP forwarding path | ||
| 124 | - [ ] Remove `update_nat()`, `firewall_enable_nat()`, `firewall_disable_nat()` from firewall.c | ||
| 125 | - [ ] Update `stop_services()` in tollgate_main.c — remove `firewall_disable_nat()` call | ||
| 126 | - [ ] Add unit test for filter function | ||
| 127 | - [ ] Build, flash, test on Board A | ||
| 128 | - [ ] Verify multi-client isolation: expire one client while other is active | ||
| 129 | |||
| 130 | ### Spent-Secret Cleanup | ||
| 131 | - [ ] Remove `s_spent_secrets[]` and `session_is_secret_spent()` from `session.c` | ||
| 132 | - [ ] Remove `spent_secrets` field from `session_t` struct in `session.h` | ||
| 133 | - [ ] Remove `spent_secrets` params from `session_create()` and `session_create_bytes()` | ||
| 134 | - [ ] Remove local spent-secret check in `tollgate_api.c` (lines 227-239) | ||
| 135 | - [ ] Remove `secrets[]` array construction in `tollgate_api.c` | ||
| 136 | - [ ] Update `tests/unit/test_session.c` — remove secret-tracking tests | ||
| 137 | - [ ] Run `make test-unit` — all tests pass | ||
| 138 | |||
| 139 | ### Integration Tests (tests/integration/) | ||
| 140 | - [ ] Create `tests/integration/` directory | ||
| 141 | - [ ] Move existing tests (api.mjs, network.mjs, smoke.mjs, phase2.mjs) into integration/ | ||
| 142 | - [ ] Write `test-reset-auth.mjs` — verify sessions cleared after reset | ||
| 143 | - [ ] Write `test-session-lifecycle.mjs` — pay → verify usage → wait expiry → verify blocked (65s) | ||
| 144 | - [ ] Write `test-dns-firewall.mjs` — DNS hijack before auth, forward after auth | ||
| 145 | - [ ] Update Makefile targets for new paths | ||
| 146 | - [ ] All integration tests passing | ||
| 147 | 84 | ||
| 148 | ### Test Reorganization | 85 | ### Test Reorganization |
| 149 | - [ ] Fix all hardcoded IPs → `process.env.TOLLGATE_IP` | 86 | - [ ] Fix hardcoded IP fallbacks: `192.168.4.1` → `10.192.45.1` in test files |
| 150 | - [ ] Move `tests/captive-portal.spec.mjs` → `tests/e2e/` | 87 | - [ ] Create `tests/integration/` and `tests/e2e/` directories |
| 151 | - [ ] Move `tests/interop-happy-path.spec.mjs` → `tests/e2e/` or `tests/integration/` | 88 | - [ ] Move `api.mjs`, `network.mjs`, `phase2.mjs`, `smoke.mjs` → `tests/integration/` |
| 152 | - [ ] Move `tests/playwright.config.mjs` → `tests/e2e/` | 89 | - [ ] Move `captive-portal.spec.mjs`, `interop-happy-path.spec.mjs` → `tests/e2e/` |
| 90 | - [ ] Move `playwright.config.mjs` → `tests/e2e/` | ||
| 91 | |||
| 92 | ### New Integration Tests | ||
| 93 | - [ ] Write `tests/integration/test-reset-auth.mjs` — reset → verify blocked → pay → verify allowed → reset → verify blocked | ||
| 94 | - [ ] Write `tests/integration/test-session-expiry.mjs` — pay → wait 65s → verify blocked (slow test) | ||
| 95 | - [ ] Write `tests/integration/test-dns-firewall.mjs` — DNS hijack before auth, forward after auth, per-client NAT filter | ||
| 96 | |||
| 97 | ### Makefile & Package Updates | ||
| 98 | - [ ] Add `test-unit`, `test-integration`, `test-e2e`, `test-all`, `test-session-expiry` targets | ||
| 99 | - [ ] Update `package.json` scripts for new paths | ||
| 100 | - [ ] Update existing targets to new paths | ||
| 153 | 101 | ||
| 154 | ### Playwright Video Recording Fix | 102 | ### Playwright Video Recording Fix |
| 155 | - [ ] Per-test context isolation (not shared serial context) | 103 | - [ ] Per-test context isolation in playwright.config.mjs |
| 156 | - [ ] Verify `.webm` files generated in test-results/ | 104 | - [ ] Verify `.webm` files generated in `tests/e2e/test-results/` |
| 157 | 105 | ||
| 158 | ### OpenWRT Interop | 106 | ### AGENTS.md Update |
| 159 | - [ ] Investigate `nofee.testnut.cashu.space` API compatibility issues | 107 | - [ ] Update firewall description: "per-client NAT filter via LWIP_HOOK_IP4_CANFORWARD" |
| 160 | - [ ] Fix cashu CLI v0.19.2 Pydantic validation failures with missing `active` field | 108 | - [ ] Update session.c description: remove "spent-secret tracking" |
| 161 | 109 | ||
| 162 | ### Board B | 110 | ### OpenWRT Interop |
| 163 | - [ ] Flash Board B with current firmware (different nsec) | 111 | - [ ] SSH to `root@10.47.41.1`, verify `tollgate-wrt` still running |
| 164 | - [ ] Cross-board payment test: Board B → Board A | 112 | - [ ] Test `curl http://10.47.41.1:2121/` — kind=10021 response |
| 165 | - [ ] ESP32→ESP32 auto-payment (Scenario 5) | 113 | - [ ] Investigate `nofee.testnut.cashu.space` API compatibility |
| 114 | - [ ] Document findings | ||
| 115 | |||
| 116 | ### Board B — Flash + Cross-Board Test | ||
| 117 | - [x] Generate nsec for Board B: `9af47906b45aca5e238390f3d03c8274e154198e81aa2095065627d1e61ca968` | ||
| 118 | - [x] Derived identity: SSID `TollGate-b96d80`, AP IP `10.185.47.1`, AP MAC `fe:08:f7:b9:6d:80` | ||
| 119 | - [ ] Create Board B config.json with new nsec | ||
| 120 | - [ ] Flash Board B at `/dev/ttyACM1` | ||
| 121 | - [ ] Verify Board B boots with different SSID/IP | ||
| 122 | - [ ] Cross-board payment test: Board B pays Board A (Scenario 5) | ||
| 166 | 123 | ||
| 167 | --- | 124 | --- |
| 168 | 125 | ||
| 169 | ## Reminders | 126 | ## Reminders |
| 170 | - **Commit + push every time a test passes that previously didn't pass** | 127 | - **Commit + push every time a test passes that previously didn't pass** |
| 171 | - Board A: `/dev/ttyACM0`, MAC `94:a9:90:2e:37:7c`, SSID `TollGate-C0E9CA`, AP IP `10.192.45.1` | 128 | - Board A: `/dev/ttyACM0`, SSID `TollGate-C0E9CA`, AP IP `10.192.45.1` |
| 172 | - Board B: `/dev/ttyACM1`, MAC `fc:01:2c:c5:50:50` | 129 | - Board B: `/dev/ttyACM1`, SSID `TollGate-b96d80`, AP IP `10.185.47.1`, nsec `9af47906...` |
| 173 | - OpenWRT Router: SSH `root@10.47.41.1`, port 2121 | 130 | - OpenWRT Router: SSH `root@10.47.41.1`, port 2121 |
| 174 | - `source ~/esp/esp-idf/export.sh` before `idf.py` | 131 | - `source ~/esp/esp-idf/export.sh` before `idf.py` |
| 175 | - Latest commit: `3342c8e` | 132 | - Latest commit: `0c2c67b` |
| 176 | - 156 unit tests + 18 Playwright tests — all passing | 133 | - 186 unit tests + 18 Playwright tests — all passing |
| 177 | - sudo password: `c03rad0r123` | 134 | - sudo password: `c03rad0r123` |
| 178 | - Token generation: `cashu -h https://testnut.cashu.space send --legacy 21` | 135 | - Token generation: `cashu -h https://testnut.cashu.space send --legacy 21` |
| 179 | - See `AGENTS.md` for full testing rules | 136 | - See `AGENTS.md` for full testing rules |
| @@ -17,12 +17,15 @@ NODE ?= node | |||
| 17 | NPM ?= npm | 17 | NPM ?= npm |
| 18 | PYTHON ?= python3 | 18 | PYTHON ?= python3 |
| 19 | 19 | ||
| 20 | TOLLGATE_IP ?= 10.192.45.1 | ||
| 21 | |||
| 20 | .PHONY: help setup detect-ports detect-chip detect-all | 22 | .PHONY: help setup detect-ports detect-chip detect-all |
| 21 | .PHONY: flash flash-a flash-b monitor monitor-a monitor-b | 23 | .PHONY: flash flash-a flash-b monitor monitor-a monitor-b |
| 22 | .PHONY: test smoke test-api test-portal test-network test-full | 24 | .PHONY: test test-unit test-integration test-e2e test-all |
| 23 | .PHONY: tokens test-payment wallet-setup wallet-info wallet-balance mint-token send-token | 25 | .PHONY: test-smoke test-api test-network test-portal test-payment |
| 24 | .PHONY: clean erase-nvs reset serial-log | 26 | .PHONY: test-reset-auth test-session-expiry test-dns-firewall |
| 25 | .PHONY: bootstrap-config | 27 | .PHONY: tokens wallet-setup wallet-info wallet-balance mint-token send-token |
| 28 | .PHONY: clean erase-nvs reset serial-log bootstrap-config | ||
| 26 | 29 | ||
| 27 | help: | 30 | help: |
| 28 | @echo "TollGate ESP32 — Makefile" | 31 | @echo "TollGate ESP32 — Makefile" |
| @@ -38,25 +41,24 @@ help: | |||
| 38 | @echo " flash-b Flash to PORT_B" | 41 | @echo " flash-b Flash to PORT_B" |
| 39 | @echo " monitor Serial monitor on PORT" | 42 | @echo " monitor Serial monitor on PORT" |
| 40 | @echo "" | 43 | @echo "" |
| 41 | @echo "Test (Phase 1):" | 44 | @echo "Testing:" |
| 42 | @echo " test Run all Phase 1 tests" | 45 | @echo " test-unit Host C unit tests (no hardware)" |
| 43 | @echo " smoke Quick 30s smoke test" | 46 | @echo " test-integration Node.js integration tests (live board)" |
| 44 | @echo " test-api curl API endpoint tests" | 47 | @echo " test-e2e Playwright browser E2E tests" |
| 45 | @echo " test-portal Playwright captive portal tests" | 48 | @echo " test-all Run all three test layers" |
| 46 | @echo " test-network DNS/NAT connectivity tests" | 49 | @echo " test-smoke Quick 30s smoke test" |
| 47 | @echo " test-full All 14 Phase 1 tests" | 50 | @echo " test-reset-auth Reset auth + per-client NAT filter test" |
| 51 | @echo " test-dns-firewall DNS hijack + NAT filter test" | ||
| 52 | @echo " test-session-expiry Session lifecycle with 65s expiry wait" | ||
| 48 | @echo "" | 53 | @echo "" |
| 49 | @echo "Test (Phase 2):" | 54 | @echo "Wallet:" |
| 50 | @echo " wallet-setup Initialize nutshell wallet for test mint" | 55 | @echo " wallet-setup Initialize nutshell wallet for test mint" |
| 51 | @echo " wallet-info Show mint info" | 56 | @echo " wallet-info Show mint info" |
| 52 | @echo " wallet-balance Show wallet balance" | 57 | @echo " wallet-balance Show wallet balance" |
| 53 | @echo " mint-token Invoice + send test token (AMOUNT=21)" | 58 | @echo " mint-token Invoice + send test token (AMOUNT=21)" |
| 54 | @echo " send-token Send cashuA token (AMOUNT=21)" | 59 | @echo " send-token Send cashuA token (AMOUNT=21)" |
| 55 | @echo " tokens Alias for send-token" | ||
| 56 | @echo " test-payment Payment flow tests" | ||
| 57 | @echo "" | 60 | @echo "" |
| 58 | @echo "Utilities:" | 61 | @echo "Utilities:" |
| 59 | @echo " setup One-time: install esptool, deps" | ||
| 60 | @echo " clean Clean build" | 62 | @echo " clean Clean build" |
| 61 | @echo " erase-nvs Erase NVS partition on PORT" | 63 | @echo " erase-nvs Erase NVS partition on PORT" |
| 62 | @echo " reset Hardware reset on PORT" | 64 | @echo " reset Hardware reset on PORT" |
| @@ -144,33 +146,60 @@ monitor-b: PORT=$(PORT_B) | |||
| 144 | monitor-b: monitor | 146 | monitor-b: monitor |
| 145 | 147 | ||
| 146 | # ────────────────────────────────────────────── | 148 | # ────────────────────────────────────────────── |
| 147 | # Test Infrastructure | 149 | # Testing |
| 148 | # ────────────────────────────────────────────── | 150 | # ────────────────────────────────────────────── |
| 149 | 151 | ||
| 150 | test: test-api test-network | 152 | test-unit: |
| 151 | @echo "=== All tests passed ===" | 153 | @echo "=== Running host unit tests ===" |
| 154 | $(MAKE) -C tests/unit test | ||
| 155 | |||
| 156 | test-integration: test-api test-network test-reset-auth test-dns-firewall | ||
| 157 | @echo "=== Integration tests passed ===" | ||
| 158 | |||
| 159 | test-e2e: | ||
| 160 | @echo "=== Running Playwright E2E tests ===" | ||
| 161 | cd tests/e2e && npx playwright test | ||
| 162 | |||
| 163 | test-all: test-unit test-integration test-e2e | ||
| 164 | @echo "=== All test layers passed ===" | ||
| 165 | |||
| 166 | test: test-unit test-integration | ||
| 167 | @echo "=== Tests passed ===" | ||
| 152 | 168 | ||
| 153 | smoke: | 169 | test-smoke: |
| 154 | @echo "=== Running smoke test (30s) ===" | 170 | @echo "=== Running smoke test (30s) ===" |
| 155 | $(NODE) tests/smoke.mjs $(PORT) | 171 | TOLLGATE_IP=$(TOLLGATE_IP) $(NODE) tests/integration/smoke.mjs |
| 156 | 172 | ||
| 157 | test-api: | 173 | test-api: |
| 158 | @echo "=== Running API tests ===" | 174 | @echo "=== Running API tests ===" |
| 159 | $(NODE) tests/api.mjs | 175 | TOLLGATE_IP=$(TOLLGATE_IP) $(NODE) tests/integration/api.mjs |
| 176 | |||
| 177 | test-network: | ||
| 178 | @echo "=== Running network tests ===" | ||
| 179 | TOLLGATE_IP=$(TOLLGATE_IP) $(NODE) tests/integration/network.mjs | ||
| 160 | 180 | ||
| 161 | test-portal: | 181 | test-portal: |
| 162 | @echo "=== Running Playwright portal tests ===" | 182 | @echo "=== Running Playwright portal tests ===" |
| 163 | npx playwright test tests/captive-portal.spec.mjs | 183 | cd tests/e2e && npx playwright test captive-portal.spec.mjs |
| 164 | 184 | ||
| 165 | test-network: | 185 | test-payment: |
| 166 | @echo "=== Running network tests ===" | 186 | @echo "=== Running payment tests ===" |
| 167 | $(NODE) tests/network.mjs | 187 | TOLLGATE_IP=$(TOLLGATE_IP) $(NODE) tests/integration/phase2.mjs |
| 188 | |||
| 189 | test-reset-auth: | ||
| 190 | @echo "=== Running reset auth test ===" | ||
| 191 | TOLLGATE_IP=$(TOLLGATE_IP) $(NODE) tests/integration/test-reset-auth.mjs | ||
| 168 | 192 | ||
| 169 | test-full: test-api test-portal test-network | 193 | test-session-expiry: |
| 170 | @echo "=== Full test suite passed ===" | 194 | @echo "=== Running session expiry test (65s wait, ~80s total) ===" |
| 195 | TOLLGATE_IP=$(TOLLGATE_IP) $(NODE) tests/integration/test-session-expiry.mjs | ||
| 196 | |||
| 197 | test-dns-firewall: | ||
| 198 | @echo "=== Running DNS + firewall test ===" | ||
| 199 | TOLLGATE_IP=$(TOLLGATE_IP) $(NODE) tests/integration/test-dns-firewall.mjs | ||
| 171 | 200 | ||
| 172 | # ────────────────────────────────────────────── | 201 | # ────────────────────────────────────────────── |
| 173 | # Phase 2: Payment Testing (Nutshell wallet) | 202 | # Wallet |
| 174 | # ────────────────────────────────────────────── | 203 | # ────────────────────────────────────────────── |
| 175 | 204 | ||
| 176 | wallet-setup: | 205 | wallet-setup: |
| @@ -187,8 +216,8 @@ wallet-balance: | |||
| 187 | cashu --env-mint $(TEST_MINT) balance | 216 | cashu --env-mint $(TEST_MINT) balance |
| 188 | 217 | ||
| 189 | mint-token: | 218 | mint-token: |
| 190 | @echo "=== Minting test token (AMOUNT=$(or $(AMOUNT),21)) ===" | ||
| 191 | @AMOUNT=$${AMOUNT:-21}; \ | 219 | @AMOUNT=$${AMOUNT:-21}; \ |
| 220 | echo "=== Minting test token ($$AMOUNT sats) ==="; \ | ||
| 192 | cashu --env-mint $(TEST_MINT) invoice $$AMOUNT && \ | 221 | cashu --env-mint $(TEST_MINT) invoice $$AMOUNT && \ |
| 193 | echo "--- Token (cashuA legacy) ---" && \ | 222 | echo "--- Token (cashuA legacy) ---" && \ |
| 194 | cashu --env-mint $(TEST_MINT) send --legacy $$AMOUNT | 223 | cashu --env-mint $(TEST_MINT) send --legacy $$AMOUNT |
| @@ -200,10 +229,6 @@ send-token: | |||
| 200 | 229 | ||
| 201 | tokens: send-token | 230 | tokens: send-token |
| 202 | 231 | ||
| 203 | test-payment: | ||
| 204 | @echo "=== Running payment tests ===" | ||
| 205 | $(NODE) tests/phase2.mjs | ||
| 206 | |||
| 207 | # ────────────────────────────────────────────── | 232 | # ────────────────────────────────────────────── |
| 208 | # Utilities | 233 | # Utilities |
| 209 | # ────────────────────────────────────────────── | 234 | # ────────────────────────────────────────────── |
diff --git a/package.json b/package.json index dd61cd9..fe1daee 100644 --- a/package.json +++ b/package.json | |||
| @@ -3,14 +3,18 @@ | |||
| 3 | "version": "1.0.0", | 3 | "version": "1.0.0", |
| 4 | "private": true, | 4 | "private": true, |
| 5 | "scripts": { | 5 | "scripts": { |
| 6 | "test": "node tests/api.mjs && node tests/network.mjs", | 6 | "test": "node tests/integration/api.mjs && node tests/integration/network.mjs", |
| 7 | "test:api": "node tests/api.mjs", | 7 | "test:api": "node tests/integration/api.mjs", |
| 8 | "test:network": "node tests/network.mjs", | 8 | "test:network": "node tests/integration/network.mjs", |
| 9 | "test:portal": "npx playwright test tests/captive-portal.spec.mjs", | 9 | "test:smoke": "node tests/integration/smoke.mjs", |
| 10 | "test:happy-path": "npx playwright test tests/interop-happy-path.spec.mjs", | 10 | "test:payment": "node tests/integration/phase2.mjs", |
| 11 | "test:interop": "npx playwright test tests/interop-esp32-openwrt.spec.mjs", | 11 | "test:reset-auth": "node tests/integration/test-reset-auth.mjs", |
| 12 | "test:smoke": "node tests/smoke.mjs", | 12 | "test:session-expiry": "node tests/integration/test-session-expiry.mjs", |
| 13 | "test:playwright": "npx playwright test" | 13 | "test:dns-firewall": "node tests/integration/test-dns-firewall.mjs", |
| 14 | "test:portal": "npx playwright test -c tests/e2e/playwright.config.mjs captive-portal.spec.mjs", | ||
| 15 | "test:happy-path": "npx playwright test -c tests/e2e/playwright.config.mjs interop-happy-path.spec.mjs", | ||
| 16 | "test:e2e": "npx playwright test -c tests/e2e/playwright.config.mjs", | ||
| 17 | "test:playwright": "npx playwright test -c tests/e2e/playwright.config.mjs" | ||
| 14 | }, | 18 | }, |
| 15 | "devDependencies": { | 19 | "devDependencies": { |
| 16 | "@playwright/test": "^1.52.0" | 20 | "@playwright/test": "^1.52.0" |
diff --git a/tests/captive-portal.spec.mjs b/tests/e2e/captive-portal.spec.mjs index 9411183..ab9d4f1 100644 --- a/tests/captive-portal.spec.mjs +++ b/tests/e2e/captive-portal.spec.mjs | |||
| @@ -1,6 +1,6 @@ | |||
| 1 | import { test, expect } from '@playwright/test'; | 1 | import { test, expect } from '@playwright/test'; |
| 2 | 2 | ||
| 3 | const PORTAL_IP = process.env.TOLLGATE_IP || '192.168.4.1'; | 3 | const PORTAL_IP = process.env.TOLLGATE_IP || '10.192.45.1'; |
| 4 | const PORTAL_URL = `http://${PORTAL_IP}`; | 4 | const PORTAL_URL = `http://${PORTAL_IP}`; |
| 5 | const API_URL = `http://${PORTAL_IP}:2121`; | 5 | const API_URL = `http://${PORTAL_IP}:2121`; |
| 6 | 6 | ||
diff --git a/tests/interop-happy-path.spec.mjs b/tests/e2e/interop-happy-path.spec.mjs index fe4fd78..fe4fd78 100644 --- a/tests/interop-happy-path.spec.mjs +++ b/tests/e2e/interop-happy-path.spec.mjs | |||
diff --git a/tests/playwright.config.mjs b/tests/e2e/playwright.config.mjs index d4118b8..f4cbe01 100644 --- a/tests/playwright.config.mjs +++ b/tests/e2e/playwright.config.mjs | |||
| @@ -9,7 +9,7 @@ export default defineConfig({ | |||
| 9 | headless: true, | 9 | headless: true, |
| 10 | viewport: { width: 1280, height: 900 }, | 10 | viewport: { width: 1280, height: 900 }, |
| 11 | screenshot: 'on', | 11 | screenshot: 'on', |
| 12 | video: 'on', | 12 | video: 'retain-on-failure', |
| 13 | trace: 'on-first-retry', | 13 | trace: 'on-first-retry', |
| 14 | }, | 14 | }, |
| 15 | reporter: [['list'], ['html', { open: 'never' }]], | 15 | reporter: [['list'], ['html', { open: 'never' }]], |
diff --git a/tests/helpers/network.mjs b/tests/helpers/network.mjs index e4d5086..a2d889e 100644 --- a/tests/helpers/network.mjs +++ b/tests/helpers/network.mjs | |||
| @@ -1,6 +1,6 @@ | |||
| 1 | import { execSync } from 'child_process'; | 1 | import { execSync } from 'child_process'; |
| 2 | 2 | ||
| 3 | const ESP32_IP = process.env.TOLLGATE_IP || '192.168.4.1'; | 3 | const ESP32_IP = process.env.TOLLGATE_IP || '10.192.45.1'; |
| 4 | const TIMEOUT = 5000; | 4 | const TIMEOUT = 5000; |
| 5 | 5 | ||
| 6 | export function curl(args, expectStatus = null) { | 6 | export function curl(args, expectStatus = null) { |
diff --git a/tests/api.mjs b/tests/integration/api.mjs index 5218d7b..5218d7b 100644 --- a/tests/api.mjs +++ b/tests/integration/api.mjs | |||
diff --git a/tests/network.mjs b/tests/integration/network.mjs index 2d302ef..dcd7a9a 100644 --- a/tests/network.mjs +++ b/tests/integration/network.mjs | |||
| @@ -1,6 +1,6 @@ | |||
| 1 | import { execSync } from 'child_process'; | 1 | import { execSync } from 'child_process'; |
| 2 | 2 | ||
| 3 | const IP = process.env.TOLLGATE_IP || '192.168.4.1'; | 3 | const IP = process.env.TOLLGATE_IP || '10.192.45.1'; |
| 4 | let passed = 0, failed = 0; | 4 | let passed = 0, failed = 0; |
| 5 | 5 | ||
| 6 | function assert(condition, test) { | 6 | function assert(condition, test) { |
diff --git a/tests/phase2.mjs b/tests/integration/phase2.mjs index 91891e7..9eaa7d7 100644 --- a/tests/phase2.mjs +++ b/tests/integration/phase2.mjs | |||
| @@ -1,6 +1,6 @@ | |||
| 1 | import { execSync } from 'child_process'; | 1 | import { execSync } from 'child_process'; |
| 2 | 2 | ||
| 3 | const IP = process.env.TOLLGATE_IP || '192.168.4.1'; | 3 | const IP = process.env.TOLLGATE_IP || '10.192.45.1'; |
| 4 | const API = `http://${IP}:2121`; | 4 | const API = `http://${IP}:2121`; |
| 5 | let passed = 0, failed = 0; | 5 | let passed = 0, failed = 0; |
| 6 | 6 | ||
diff --git a/tests/smoke.mjs b/tests/integration/smoke.mjs index 19f96de..f89eeac 100644 --- a/tests/smoke.mjs +++ b/tests/integration/smoke.mjs | |||
| @@ -1,7 +1,7 @@ | |||
| 1 | import { execSync } from 'child_process'; | 1 | import { execSync } from 'child_process'; |
| 2 | 2 | ||
| 3 | const PORT = process.argv[2] || '/dev/ttyACM0'; | 3 | const PORT = process.argv[2] || '/dev/ttyACM0'; |
| 4 | const IP = process.env.TOLLGATE_IP || '192.168.4.1'; | 4 | const IP = process.env.TOLLGATE_IP || '10.192.45.1'; |
| 5 | const SSID = process.env.AP_SSID || 'TollGate'; | 5 | const SSID = process.env.AP_SSID || 'TollGate'; |
| 6 | 6 | ||
| 7 | console.log(`\n=== Smoke Test (30s) ===`); | 7 | console.log(`\n=== Smoke Test (30s) ===`); |
diff --git a/tests/integration/test-dns-firewall.mjs b/tests/integration/test-dns-firewall.mjs new file mode 100644 index 0000000..b69b524 --- /dev/null +++ b/tests/integration/test-dns-firewall.mjs | |||
| @@ -0,0 +1,123 @@ | |||
| 1 | import { execSync } from 'child_process'; | ||
| 2 | |||
| 3 | const IP = process.env.TOLLGATE_IP || '10.192.45.1'; | ||
| 4 | const API = `http://${IP}:2121`; | ||
| 5 | let passed = 0, failed = 0; | ||
| 6 | |||
| 7 | function assert(cond, msg) { | ||
| 8 | if (cond) { console.log(` ✓ ${msg}`); passed++; } | ||
| 9 | else { console.log(` ✗ ${msg}`); failed++; } | ||
| 10 | } | ||
| 11 | |||
| 12 | function run(cmd) { | ||
| 13 | try { return execSync(cmd, { encoding: 'utf8', timeout: 15000 }); } | ||
| 14 | catch { return null; } | ||
| 15 | } | ||
| 16 | |||
| 17 | function runJson(cmd) { | ||
| 18 | const out = run(cmd); | ||
| 19 | try { return out ? JSON.parse(out) : null; } | ||
| 20 | catch { return null; } | ||
| 21 | } | ||
| 22 | |||
| 23 | function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } | ||
| 24 | |||
| 25 | function mintToken(amount = 21) { | ||
| 26 | run('cashu -h https://testnut.cashu.space invoice ' + amount + ' 2>&1'); | ||
| 27 | const out = run('cashu -h https://testnut.cashu.space send --legacy ' + amount + ' 2>&1'); | ||
| 28 | const match = out && out.match(/cashuA[a-zA-Z0-9_-]+/); | ||
| 29 | return match ? match[0] : null; | ||
| 30 | } | ||
| 31 | |||
| 32 | function dnsResolves(domain, server) { | ||
| 33 | const result = run(`nslookup -timeout=3 ${domain} ${server} 2>&1`); | ||
| 34 | return result && result.includes('Address') && !result.includes('NXDOMAIN'); | ||
| 35 | } | ||
| 36 | |||
| 37 | function dnsResolvesToSelf(domain) { | ||
| 38 | try { | ||
| 39 | const result = run(`nslookup ${domain} ${IP} 2>&1`); | ||
| 40 | return result && result.includes(IP); | ||
| 41 | } catch { | ||
| 42 | return false; | ||
| 43 | } | ||
| 44 | } | ||
| 45 | |||
| 46 | function canPing(host = '8.8.8.8') { | ||
| 47 | const result = run(`ping -c 1 -W 2 -I wlp59s0 ${host} 2>/dev/null`); | ||
| 48 | return result && !result.includes('100% packet loss'); | ||
| 49 | } | ||
| 50 | |||
| 51 | console.log(`\n=== DNS + Firewall Integration Test (target: ${IP}) ===\n`); | ||
| 52 | |||
| 53 | console.log('--- Part 1: Before Authentication ---\n'); | ||
| 54 | |||
| 55 | console.log('1. DNS hijack: resolves to ESP32 AP IP'); | ||
| 56 | assert(dnsResolvesToSelf('google.com'), 'google.com resolves to AP IP'); | ||
| 57 | assert(dnsResolvesToSelf('random-test.example.com'), 'random domain resolves to AP IP'); | ||
| 58 | |||
| 59 | console.log('\n2. DNS hijack: upstream DNS not reachable'); | ||
| 60 | const upstreamResolve = run(`nslookup -timeout=3 google.com 8.8.8.8 2>&1`); | ||
| 61 | assert(!upstreamResolve || upstreamResolve.includes('connection timed out') || upstreamResolve.includes('no servers'), 'Upstream DNS unreachable before auth'); | ||
| 62 | |||
| 63 | console.log('\n3. Per-client NAT filter: ping blocked'); | ||
| 64 | assert(!canPing(), 'Ping to 8.8.8.8 blocked by NAT filter'); | ||
| 65 | |||
| 66 | console.log('\n4. Per-client NAT filter: HTTP blocked'); | ||
| 67 | const httpBefore = run(`curl -s --connect-timeout 5 -m 5 --interface wlp59s0 http://1.1.1.1/ 2>/dev/null`); | ||
| 68 | assert(!httpBefore || httpBefore.length === 0, 'HTTP blocked before auth'); | ||
| 69 | |||
| 70 | console.log('\n5. Captive portal and API still accessible'); | ||
| 71 | const portal = run(`curl -s --connect-timeout 5 http://${IP}/`); | ||
| 72 | assert(portal && portal.includes('TollGate'), 'Portal HTML accessible'); | ||
| 73 | const apiDisc = runJson(`curl -s --connect-timeout 5 ${API}/`); | ||
| 74 | assert(apiDisc && apiDisc.kind === 10021, 'API discovery accessible'); | ||
| 75 | |||
| 76 | console.log('\n--- Part 2: After Authentication ---\n'); | ||
| 77 | |||
| 78 | console.log('6. Reset + Pay'); | ||
| 79 | run(`curl -s --connect-timeout 10 http://${IP}/reset_authentication`); | ||
| 80 | await sleep(1000); | ||
| 81 | |||
| 82 | const token = mintToken(21); | ||
| 83 | assert(token !== null, 'Token generated'); | ||
| 84 | if (token) { | ||
| 85 | const payResult = runJson(`curl -s --connect-timeout 20 -X POST --data-binary '${token}' -H "Content-Type: application/cashu" ${API}/`); | ||
| 86 | assert(payResult && payResult.kind === 1022, 'Payment accepted'); | ||
| 87 | } | ||
| 88 | |||
| 89 | await sleep(1000); | ||
| 90 | |||
| 91 | console.log('\n7. DNS now forwards to upstream'); | ||
| 92 | assert(dnsResolveWorks('google.com'), 'DNS resolves to real IPs after auth'); | ||
| 93 | |||
| 94 | console.log('\n8. Per-client NAT filter: ping allowed'); | ||
| 95 | assert(canPing(), 'Ping to 8.8.8.8 allowed after auth'); | ||
| 96 | |||
| 97 | console.log('\n9. Per-client NAT filter: HTTP allowed'); | ||
| 98 | const httpAfter = run(`curl -s --connect-timeout 10 -m 10 --interface wlp59s0 http://1.1.1.1/ 2>/dev/null`); | ||
| 99 | assert(httpAfter && httpAfter.length > 0, 'HTTP allowed after auth'); | ||
| 100 | |||
| 101 | console.log('\n--- Part 3: After Revocation ---\n'); | ||
| 102 | |||
| 103 | console.log('10. Reset auth'); | ||
| 104 | run(`curl -s --connect-timeout 10 http://${IP}/reset_authentication`); | ||
| 105 | await sleep(1000); | ||
| 106 | |||
| 107 | console.log('\n11. DNS goes back to hijack'); | ||
| 108 | assert(dnsResolvesToSelf('google.com'), 'DNS hijack restored after revoke'); | ||
| 109 | |||
| 110 | console.log('\n12. Per-client NAT filter: ping blocked again'); | ||
| 111 | assert(!canPing(), 'Ping blocked after revoke'); | ||
| 112 | |||
| 113 | console.log('\n13. Per-client NAT filter: HTTP blocked again'); | ||
| 114 | const httpRevoke = run(`curl -s --connect-timeout 5 -m 5 --interface wlp59s0 http://1.1.1.1/ 2>/dev/null`); | ||
| 115 | assert(!httpRevoke || httpRevoke.length === 0, 'HTTP blocked after revoke'); | ||
| 116 | |||
| 117 | function dnsResolveWorks(domain) { | ||
| 118 | const result = run(`nslookup -timeout=3 ${domain} 2>&1`); | ||
| 119 | return result && result.includes('Address') && !result.includes(IP) && !result.includes('NXDOMAIN'); | ||
| 120 | } | ||
| 121 | |||
| 122 | console.log(`\n=== Results: ${passed} passed, ${failed} failed ===\n`); | ||
| 123 | process.exit(failed > 0 ? 1 : 0); | ||
diff --git a/tests/integration/test-reset-auth.mjs b/tests/integration/test-reset-auth.mjs new file mode 100644 index 0000000..279b2f9 --- /dev/null +++ b/tests/integration/test-reset-auth.mjs | |||
| @@ -0,0 +1,101 @@ | |||
| 1 | import { execSync } from 'child_process'; | ||
| 2 | |||
| 3 | const IP = process.env.TOLLGATE_IP || '10.192.45.1'; | ||
| 4 | const API = `http://${IP}:2121`; | ||
| 5 | const SUDO_PW = process.env.SUDO_PW || 'c03rad0r123'; | ||
| 6 | let passed = 0, failed = 0; | ||
| 7 | |||
| 8 | function assert(cond, msg) { | ||
| 9 | if (cond) { console.log(` ✓ ${msg}`); passed++; } | ||
| 10 | else { console.log(` ✗ ${msg}`); failed++; } | ||
| 11 | } | ||
| 12 | |||
| 13 | function run(cmd) { | ||
| 14 | try { return execSync(cmd, { encoding: 'utf8', timeout: 15000 }); } | ||
| 15 | catch { return null; } | ||
| 16 | } | ||
| 17 | |||
| 18 | function runJson(cmd) { | ||
| 19 | const out = run(cmd); | ||
| 20 | try { return out ? JSON.parse(out) : null; } | ||
| 21 | catch { return null; } | ||
| 22 | } | ||
| 23 | |||
| 24 | function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } | ||
| 25 | |||
| 26 | function mintToken(amount = 21) { | ||
| 27 | run('cashu -h https://testnut.cashu.space invoice ' + amount + ' 2>&1'); | ||
| 28 | const out = run('cashu -h https://testnut.cashu.space send --legacy ' + amount + ' 2>&1'); | ||
| 29 | const match = out && out.match(/cashuA[a-zA-Z0-9_-]+/); | ||
| 30 | return match ? match[0] : null; | ||
| 31 | } | ||
| 32 | |||
| 33 | function canPing(host = '8.8.8.8') { | ||
| 34 | const result = run(`ping -c 1 -W 2 -I wlp59s0 ${host} 2>/dev/null`); | ||
| 35 | return result && !result.includes('100% packet loss'); | ||
| 36 | } | ||
| 37 | |||
| 38 | console.log(`\n=== Reset Auth Integration Test (target: ${IP}) ===\n`); | ||
| 39 | |||
| 40 | console.log('1. Reset auth to clear state'); | ||
| 41 | const reset1 = run(`curl -s --connect-timeout 10 http://${IP}/reset_authentication`); | ||
| 42 | assert(reset1 && reset1.includes('reset'), 'Reset returns {"status":"reset"}'); | ||
| 43 | |||
| 44 | await sleep(1000); | ||
| 45 | |||
| 46 | console.log('\n2. Verify no session'); | ||
| 47 | const usage1 = run(`curl -s --connect-timeout 10 ${API}/usage`); | ||
| 48 | assert(usage1 && usage1.includes('-1/-1'), 'Usage is -1/-1 before payment'); | ||
| 49 | |||
| 50 | console.log('\n3. Verify internet blocked'); | ||
| 51 | assert(!canPing(), 'Ping blocked before payment'); | ||
| 52 | |||
| 53 | console.log('\n4. Pay with valid token'); | ||
| 54 | const token = mintToken(21); | ||
| 55 | assert(token !== null, 'Token generated'); | ||
| 56 | if (token) { | ||
| 57 | const payResult = runJson(`curl -s --connect-timeout 20 -X POST --data-binary '${token}' -H "Content-Type: application/cashu" ${API}/`); | ||
| 58 | assert(payResult && payResult.kind === 1022, 'Payment accepted (kind=1022)'); | ||
| 59 | const allotment = payResult && payResult.tags && payResult.tags.find(t => t[0] === 'allotment'); | ||
| 60 | assert(allotment && parseInt(allotment[1]) > 0, `Allotment: ${allotment ? allotment[1] : 'N/A'}ms`); | ||
| 61 | } | ||
| 62 | |||
| 63 | await sleep(1000); | ||
| 64 | |||
| 65 | console.log('\n5. Verify session active'); | ||
| 66 | const usage2 = run(`curl -s --connect-timeout 10 ${API}/usage`); | ||
| 67 | assert(usage2 && !usage2.includes('-1/-1'), `Usage: ${usage2}`); | ||
| 68 | |||
| 69 | console.log('\n6. Verify internet allowed'); | ||
| 70 | assert(canPing(), 'Ping works with active session'); | ||
| 71 | |||
| 72 | console.log('\n7. Reset auth while session active'); | ||
| 73 | const reset2 = run(`curl -s --connect-timeout 10 http://${IP}/reset_authentication`); | ||
| 74 | assert(reset2 && reset2.includes('reset'), 'Reset returns {"status":"reset"}'); | ||
| 75 | |||
| 76 | await sleep(1000); | ||
| 77 | |||
| 78 | console.log('\n8. Verify session cleared'); | ||
| 79 | const usage3 = run(`curl -s --connect-timeout 10 ${API}/usage`); | ||
| 80 | assert(usage3 && usage3.includes('-1/-1'), 'Usage is -1/-1 after reset'); | ||
| 81 | |||
| 82 | console.log('\n9. Verify internet blocked again'); | ||
| 83 | assert(!canPing(), 'Ping blocked after reset'); | ||
| 84 | |||
| 85 | console.log('\n10. Pay again (new token)'); | ||
| 86 | const token2 = mintToken(21); | ||
| 87 | if (token2) { | ||
| 88 | const pay2 = runJson(`curl -s --connect-timeout 20 -X POST --data-binary '${token2}' -H "Content-Type: application/cashu" ${API}/`); | ||
| 89 | assert(pay2 && pay2.kind === 1022, 'Second payment accepted'); | ||
| 90 | } | ||
| 91 | |||
| 92 | await sleep(1000); | ||
| 93 | |||
| 94 | console.log('\n11. Verify internet works again'); | ||
| 95 | assert(canPing(), 'Ping works with new session'); | ||
| 96 | |||
| 97 | console.log('\n12. Final reset'); | ||
| 98 | run(`curl -s --connect-timeout 10 http://${IP}/reset_authentication`); | ||
| 99 | |||
| 100 | console.log(`\n=== Results: ${passed} passed, ${failed} failed ===\n`); | ||
| 101 | process.exit(failed > 0 ? 1 : 0); | ||
diff --git a/tests/integration/test-session-expiry.mjs b/tests/integration/test-session-expiry.mjs new file mode 100644 index 0000000..c8334ab --- /dev/null +++ b/tests/integration/test-session-expiry.mjs | |||
| @@ -0,0 +1,103 @@ | |||
| 1 | import { execSync } from 'child_process'; | ||
| 2 | |||
| 3 | const IP = process.env.TOLLGATE_IP || '10.192.45.1'; | ||
| 4 | const API = `http://${IP}:2121`; | ||
| 5 | let passed = 0, failed = 0; | ||
| 6 | |||
| 7 | function assert(cond, msg) { | ||
| 8 | if (cond) { console.log(` ✓ ${msg}`); passed++; } | ||
| 9 | else { console.log(` ✗ ${msg}`); failed++; } | ||
| 10 | } | ||
| 11 | |||
| 12 | function run(cmd) { | ||
| 13 | try { return execSync(cmd, { encoding: 'utf8', timeout: 15000 }); } | ||
| 14 | catch { return null; } | ||
| 15 | } | ||
| 16 | |||
| 17 | function runJson(cmd) { | ||
| 18 | const out = run(cmd); | ||
| 19 | try { return out ? JSON.parse(out) : null; } | ||
| 20 | catch { return null; } | ||
| 21 | } | ||
| 22 | |||
| 23 | function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } | ||
| 24 | |||
| 25 | function mintToken(amount = 21) { | ||
| 26 | run('cashu -h https://testnut.cashu.space invoice ' + amount + ' 2>&1'); | ||
| 27 | const out = run('cashu -h https://testnut.cashu.space send --legacy ' + amount + ' 2>&1'); | ||
| 28 | const match = out && out.match(/cashuA[a-zA-Z0-9_-]+/); | ||
| 29 | return match ? match[0] : null; | ||
| 30 | } | ||
| 31 | |||
| 32 | function canPing(host = '8.8.8.8') { | ||
| 33 | const result = run(`ping -c 1 -W 2 -I wlp59s0 ${host} 2>/dev/null`); | ||
| 34 | return result && !result.includes('100% packet loss'); | ||
| 35 | } | ||
| 36 | |||
| 37 | console.log(`\n=== Session Expiry Integration Test (target: ${IP}) ===`); | ||
| 38 | console.log(`NOTE: This test waits 65s for session expiry. Total runtime ~80s.\n`); | ||
| 39 | |||
| 40 | console.log('1. Reset auth'); | ||
| 41 | run(`curl -s --connect-timeout 10 http://${IP}/reset_authentication`); | ||
| 42 | |||
| 43 | await sleep(1000); | ||
| 44 | |||
| 45 | console.log('\n2. Verify blocked before payment'); | ||
| 46 | assert(!canPing(), 'Ping blocked before payment'); | ||
| 47 | |||
| 48 | const usage0 = run(`curl -s --connect-timeout 10 ${API}/usage`); | ||
| 49 | assert(usage0 && usage0.includes('-1/-1'), 'Usage is -1/-1'); | ||
| 50 | |||
| 51 | console.log('\n3. Pay with valid token (21 sats = 60000ms)'); | ||
| 52 | const token = mintToken(21); | ||
| 53 | assert(token !== null, 'Token generated'); | ||
| 54 | if (token) { | ||
| 55 | const payResult = runJson(`curl -s --connect-timeout 20 -X POST --data-binary '${token}' -H "Content-Type: application/cashu" ${API}/`); | ||
| 56 | assert(payResult && payResult.kind === 1022, 'Payment accepted'); | ||
| 57 | } | ||
| 58 | |||
| 59 | await sleep(1000); | ||
| 60 | |||
| 61 | console.log('\n4. Verify session active'); | ||
| 62 | const usage1 = run(`curl -s --connect-timeout 10 ${API}/usage`); | ||
| 63 | assert(usage1 && !usage1.includes('-1/-1'), `Usage: ${usage1}`); | ||
| 64 | |||
| 65 | console.log('\n5. Verify internet works'); | ||
| 66 | assert(canPing(), 'Ping works with active session'); | ||
| 67 | |||
| 68 | const httpResult = run(`curl -s --connect-timeout 10 -m 10 --interface wlp59s0 http://1.1.1.1/ 2>/dev/null`); | ||
| 69 | assert(httpResult && httpResult.length > 0, 'HTTP request reaches internet'); | ||
| 70 | |||
| 71 | console.log('\n6. Waiting 65s for session expiry (allotment=60000ms)...'); | ||
| 72 | for (let i = 65; i > 0; i -= 5) { | ||
| 73 | process.stdout.write(`\r ${i}s remaining...`); | ||
| 74 | await sleep(Math.min(5000, i * 1000)); | ||
| 75 | } | ||
| 76 | console.log('\r Session should be expired now. '); | ||
| 77 | |||
| 78 | console.log('\n7. Verify session expired'); | ||
| 79 | const usage2 = run(`curl -s --connect-timeout 10 ${API}/usage`); | ||
| 80 | assert(usage2 && usage2.includes('-1/-1'), `Usage after expiry: ${usage2}`); | ||
| 81 | |||
| 82 | console.log('\n8. Verify internet blocked after expiry'); | ||
| 83 | assert(!canPing(), 'Ping blocked after session expiry'); | ||
| 84 | |||
| 85 | const httpResult2 = run(`curl -s --connect-timeout 5 -m 5 --interface wlp59s0 http://1.1.1.1/ 2>/dev/null`); | ||
| 86 | assert(!httpResult2 || httpResult2.length === 0, 'HTTP blocked after expiry'); | ||
| 87 | |||
| 88 | console.log('\n9. Pay again to verify renewal works'); | ||
| 89 | const token2 = mintToken(21); | ||
| 90 | if (token2) { | ||
| 91 | const pay2 = runJson(`curl -s --connect-timeout 20 -X POST --data-binary '${token2}' -H "Content-Type: application/cashu" ${API}/`); | ||
| 92 | assert(pay2 && pay2.kind === 1022, 'Renewal payment accepted'); | ||
| 93 | } | ||
| 94 | |||
| 95 | await sleep(1000); | ||
| 96 | |||
| 97 | console.log('\n10. Verify internet works after renewal'); | ||
| 98 | assert(canPing(), 'Ping works after renewal'); | ||
| 99 | |||
| 100 | run(`curl -s --connect-timeout 10 http://${IP}/reset_authentication`); | ||
| 101 | |||
| 102 | console.log(`\n=== Results: ${passed} passed, ${failed} failed ===\n`); | ||
| 103 | process.exit(failed > 0 ? 1 : 0); | ||