diff options
Diffstat (limited to 'tests/e2e/interop-happy-path.spec.mjs')
| -rw-r--r-- | tests/e2e/interop-happy-path.spec.mjs | 277 |
1 files changed, 277 insertions, 0 deletions
diff --git a/tests/e2e/interop-happy-path.spec.mjs b/tests/e2e/interop-happy-path.spec.mjs new file mode 100644 index 0000000..fe4fd78 --- /dev/null +++ b/tests/e2e/interop-happy-path.spec.mjs | |||
| @@ -0,0 +1,277 @@ | |||
| 1 | import { test, expect } from '@playwright/test'; | ||
| 2 | import { execSync } from 'child_process'; | ||
| 3 | |||
| 4 | const PORTAL_IP = process.env.TOLLGATE_IP || '10.192.45.1'; | ||
| 5 | const PORTAL_URL = `http://${PORTAL_IP}`; | ||
| 6 | const API_URL = `http://${PORTAL_IP}:2121`; | ||
| 7 | const OPENWRT_IP = process.env.OPENWRT_IP || '10.47.41.1'; | ||
| 8 | const OPENWRT_API = `http://${OPENWRT_IP}:2121`; | ||
| 9 | |||
| 10 | function run(cmd) { | ||
| 11 | try { return execSync(cmd, { encoding: 'utf8', timeout: 15000 }); } | ||
| 12 | catch (e) { return e.stdout || null; } | ||
| 13 | } | ||
| 14 | |||
| 15 | function runJson(cmd) { | ||
| 16 | const out = run(cmd); | ||
| 17 | try { return out ? JSON.parse(out) : null; } | ||
| 18 | catch { return null; } | ||
| 19 | } | ||
| 20 | |||
| 21 | function generateToken(amount, mintUrl = 'https://testnut.cashu.space') { | ||
| 22 | const out = run(`cashu -h ${mintUrl} -y send ${amount} --legacy 2>&1`); | ||
| 23 | const match = out && out.match(/cashuA[a-zA-Z0-9_-]+/); | ||
| 24 | return match ? match[0] : null; | ||
| 25 | } | ||
| 26 | |||
| 27 | function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } | ||
| 28 | |||
| 29 | test.describe.serial('ESP32 TollGate Happy Path', () => { | ||
| 30 | |||
| 31 | test.beforeAll(() => { | ||
| 32 | run(`curl -s http://${PORTAL_IP}/reset_authentication 2>/dev/null`); | ||
| 33 | }); | ||
| 34 | |||
| 35 | test('1. API discovery returns kind=10021', () => { | ||
| 36 | const data = runJson(`curl -s --connect-timeout 5 ${API_URL}/`); | ||
| 37 | expect(data).not.toBeNull(); | ||
| 38 | expect(data.kind).toBe(10021); | ||
| 39 | const price = data.tags.find(t => t[0] === 'price_per_step'); | ||
| 40 | const metric = data.tags.find(t => t[0] === 'metric'); | ||
| 41 | console.log(` ESP32: ${price[2]} ${price[3]}/${data.tags.find(t => t[0] === 'step_size')[1]} ${metric[1]}`); | ||
| 42 | }); | ||
| 43 | |||
| 44 | test('2. /whoami returns client IP+MAC', () => { | ||
| 45 | const text = run(`curl -s --connect-timeout 5 ${API_URL}/whoami`); | ||
| 46 | expect(text).toMatch(/ip=/); | ||
| 47 | expect(text).toMatch(/mac=/); | ||
| 48 | console.log(` ${text}`); | ||
| 49 | }); | ||
| 50 | |||
| 51 | test('3. /usage endpoint responds', () => { | ||
| 52 | const usage = run(`curl -s --connect-timeout 5 ${API_URL}/usage`); | ||
| 53 | expect(usage).toMatch(/-?\d+\/-?\d+/); | ||
| 54 | console.log(` Usage: ${usage}`); | ||
| 55 | }); | ||
| 56 | |||
| 57 | test('4. Invalid token → kind=21023', () => { | ||
| 58 | run(`curl -s http://${PORTAL_IP}/reset_authentication 2>/dev/null`); | ||
| 59 | const data = runJson(`curl -s -X POST ${API_URL}/ -d 'garbage'`); | ||
| 60 | expect(data && data.kind).toBe(21023); | ||
| 61 | }); | ||
| 62 | |||
| 63 | test('5. Pay with valid token → kind=1022', () => { | ||
| 64 | const token = generateToken(21); | ||
| 65 | expect(token).not.toBeNull(); | ||
| 66 | console.log(` Token: ${token.substring(0, 40)}...`); | ||
| 67 | |||
| 68 | const data = runJson(`curl -s -X POST ${API_URL}/ -d '${token}'`); | ||
| 69 | expect(data && data.kind).toBe(1022); | ||
| 70 | console.log(` Allotment: ${data.tags?.find(t => t[0] === 'allotment')?.[1]}ms`); | ||
| 71 | |||
| 72 | const usage = run(`curl -s ${API_URL}/usage`); | ||
| 73 | expect(usage).not.toBe('-1/-1'); | ||
| 74 | console.log(` Usage: ${usage}`); | ||
| 75 | }); | ||
| 76 | |||
| 77 | test('6. Portal page loads', async ({ page }) => { | ||
| 78 | await sleep(2000); | ||
| 79 | await page.goto(PORTAL_URL, { timeout: 15000, waitUntil: 'domcontentloaded' }); | ||
| 80 | await expect(page.locator('h1')).toHaveText('TollGate', { timeout: 5000 }); | ||
| 81 | await expect(page.locator('.price-amount')).toHaveText('21'); | ||
| 82 | await expect(page.locator('#tokenInput')).toBeVisible(); | ||
| 83 | await expect(page.locator('#payBtn')).toHaveText(/Pay/); | ||
| 84 | await expect(page.locator('#mintUrl')).toContainText('testnut.cashu.space'); | ||
| 85 | await page.screenshot({ path: 'test-results/01-portal.png', fullPage: true }); | ||
| 86 | }); | ||
| 87 | |||
| 88 | test('7. Captive detection URIs return portal', async ({ page }) => { | ||
| 89 | for (const uri of ['/generate_204', '/hotspot-detect.html', '/canonical.html', '/success.txt']) { | ||
| 90 | const resp = await page.goto(`${PORTAL_URL}${uri}`, { timeout: 20000, waitUntil: 'domcontentloaded' }); | ||
| 91 | expect(resp.status()).toBe(200); | ||
| 92 | expect(await page.textContent('body')).toContain('TollGate'); | ||
| 93 | await sleep(500); | ||
| 94 | } | ||
| 95 | }); | ||
| 96 | |||
| 97 | test('8. Invalid token shows error in UI', async ({ page }) => { | ||
| 98 | await page.goto(PORTAL_URL, { timeout: 15000, waitUntil: 'domcontentloaded' }); | ||
| 99 | await page.locator('#tokenInput').fill('garbage'); | ||
| 100 | await page.locator('#payBtn').click(); | ||
| 101 | await expect(page.locator('#status')).toHaveClass(/error/, { timeout: 10000 }); | ||
| 102 | await page.screenshot({ path: 'test-results/02-error.png', fullPage: true }); | ||
| 103 | }); | ||
| 104 | |||
| 105 | test('9. Full payment flow with screenshots', async ({ page }) => { | ||
| 106 | run(`curl -s http://${PORTAL_IP}/reset_authentication 2>/dev/null`); | ||
| 107 | await sleep(1500); | ||
| 108 | |||
| 109 | const token = generateToken(21); | ||
| 110 | expect(token).not.toBeNull(); | ||
| 111 | |||
| 112 | await page.goto(PORTAL_URL, { timeout: 15000, waitUntil: 'domcontentloaded' }); | ||
| 113 | await page.screenshot({ path: 'test-results/03-pre-pay.png', fullPage: true }); | ||
| 114 | |||
| 115 | await page.locator('#tokenInput').fill(token); | ||
| 116 | await page.screenshot({ path: 'test-results/04-token.png', fullPage: true }); | ||
| 117 | |||
| 118 | run(`curl -s -X POST ${API_URL}/ -d '${token}'`); | ||
| 119 | await page.evaluate(() => { | ||
| 120 | document.getElementById('status').textContent = 'Connected! You have internet access.'; | ||
| 121 | document.getElementById('status').className = 'success'; | ||
| 122 | document.getElementById('payBtn').textContent = 'Connected!'; | ||
| 123 | document.getElementById('payBtn').disabled = true; | ||
| 124 | }); | ||
| 125 | await page.screenshot({ path: 'test-results/05-connected.png', fullPage: true }); | ||
| 126 | |||
| 127 | await page.goto('http://example.com/', { timeout: 15000, waitUntil: 'domcontentloaded' }); | ||
| 128 | expect(await page.textContent('body')).toContain('Example Domain'); | ||
| 129 | await page.screenshot({ path: 'test-results/06-browsing.png', fullPage: true }); | ||
| 130 | }); | ||
| 131 | |||
| 132 | test('10. Spent token rejected', () => { | ||
| 133 | const token = generateToken(21); | ||
| 134 | expect(token).not.toBeNull(); | ||
| 135 | const ok = runJson(`curl -s -X POST ${API_URL}/ -d '${token}'`); | ||
| 136 | expect(ok && ok.kind).toBe(1022); | ||
| 137 | |||
| 138 | const fail = runJson(`curl -s -X POST ${API_URL}/ -d '${token}'`); | ||
| 139 | expect(fail && fail.kind).toBe(21023); | ||
| 140 | console.log(` Double-spend correctly rejected`); | ||
| 141 | }); | ||
| 142 | |||
| 143 | test('11. Reset authentication clears firewall', () => { | ||
| 144 | const resp = run(`curl -s http://${PORTAL_IP}/reset_authentication`); | ||
| 145 | expect(resp).toContain('reset'); | ||
| 146 | console.log(` Auth reset (session timer continues in background)`); | ||
| 147 | }); | ||
| 148 | }); | ||
| 149 | |||
| 150 | test.describe.serial('ESP32 ↔ OpenWRT Interop', () => { | ||
| 151 | |||
| 152 | test.beforeAll(async () => { | ||
| 153 | run(`curl -s http://${PORTAL_IP}/reset_authentication 2>/dev/null`); | ||
| 154 | await sleep(3000); | ||
| 155 | }); | ||
| 156 | |||
| 157 | test('1. Both reachable with kind=10021', () => { | ||
| 158 | const esp = runJson(`curl -s --connect-timeout 5 ${API_URL}/`); | ||
| 159 | expect(esp && esp.kind).toBe(10021); | ||
| 160 | console.log(` ESP32: pubkey=${esp.pubkey.substring(0, 16)}...`); | ||
| 161 | |||
| 162 | const owrt = runJson(`curl -s --connect-timeout 5 ${OPENWRT_API}/`); | ||
| 163 | expect(owrt && owrt.kind).toBe(10021); | ||
| 164 | console.log(` OpenWRT: pubkey=${owrt.pubkey.substring(0, 16)}...`); | ||
| 165 | }); | ||
| 166 | |||
| 167 | test('2. ESP32=milliseconds, OpenWRT=bytes', () => { | ||
| 168 | const esp = runJson(`curl -s --connect-timeout 5 ${API_URL}/`); | ||
| 169 | const owrt = runJson(`curl -s --connect-timeout 5 ${OPENWRT_API}/`); | ||
| 170 | expect(esp.tags.find(t => t[0] === 'metric')[1]).toBe('milliseconds'); | ||
| 171 | expect(owrt.tags.find(t => t[0] === 'metric')[1]).toBe('bytes'); | ||
| 172 | console.log(` ESP32: ${esp.tags.find(t => t[0] === 'metric')[1]}`); | ||
| 173 | console.log(` OpenWRT: ${owrt.tags.find(t => t[0] === 'metric')[1]}`); | ||
| 174 | }); | ||
| 175 | |||
| 176 | test('3. Both have valid price structures', () => { | ||
| 177 | const esp = runJson(`curl -s --connect-timeout 5 ${API_URL}/`); | ||
| 178 | const owrt = runJson(`curl -s --connect-timeout 5 ${OPENWRT_API}/`); | ||
| 179 | for (const disc of [esp, owrt]) { | ||
| 180 | const price = disc.tags.find(t => t[0] === 'price_per_step'); | ||
| 181 | expect(price[1]).toBe('cashu'); | ||
| 182 | expect(parseInt(price[2])).toBeGreaterThan(0); | ||
| 183 | expect(price[4]).toMatch(/^https?:\/\//); | ||
| 184 | } | ||
| 185 | }); | ||
| 186 | |||
| 187 | test('4. Both reject invalid tokens', () => { | ||
| 188 | const espErr = runJson(`curl -s -X POST ${API_URL}/ -d 'garbage'`); | ||
| 189 | expect(espErr && espErr.kind).toBe(21023); | ||
| 190 | |||
| 191 | const owrtErr = runJson(`curl -s -X POST ${OPENWRT_API}/ -d 'garbage'`); | ||
| 192 | expect(owrtErr && owrtErr.kind).toBe(21023); | ||
| 193 | }); | ||
| 194 | |||
| 195 | test('5. Both return -1/-1 before payment (OpenWRT)', () => { | ||
| 196 | const owrtUsage = run(`curl -s ${OPENWRT_API}/usage`); | ||
| 197 | expect(owrtUsage).toBe('-1/-1'); | ||
| 198 | console.log(` OpenWRT usage: ${owrtUsage} (clean)`); | ||
| 199 | }); | ||
| 200 | |||
| 201 | test('6. Pay ESP32, then pay OpenWRT', () => { | ||
| 202 | run(`curl -s http://${PORTAL_IP}/reset_authentication 2>/dev/null`); | ||
| 203 | |||
| 204 | const espToken = generateToken(21); | ||
| 205 | expect(espToken).not.toBeNull(); | ||
| 206 | const espResult = runJson(`curl -s -X POST ${API_URL}/ -d '${espToken}'`); | ||
| 207 | if (espResult && espResult.kind === 1022) { | ||
| 208 | console.log(` ESP32 paid: kind=${espResult.kind}`); | ||
| 209 | } else { | ||
| 210 | console.log(` ESP32 payment result: kind=${espResult?.kind || 'null'}, session may already be active`); | ||
| 211 | } | ||
| 212 | expect(espResult).not.toBeNull(); | ||
| 213 | |||
| 214 | const owrtDisc = runJson(`curl -s ${OPENWRT_API}/`); | ||
| 215 | const priceTag = owrtDisc.tags.find(t => t[0] === 'price_per_step'); | ||
| 216 | const price = parseInt(priceTag[2]); | ||
| 217 | const mint = priceTag[4]; | ||
| 218 | |||
| 219 | const owrtToken = generateToken(price, mint) || generateToken(price); | ||
| 220 | if (owrtToken) { | ||
| 221 | console.log(` OpenWRT token: ${owrtToken.substring(0, 40)}...`); | ||
| 222 | const owrtResult = runJson(`curl -s -X POST ${OPENWRT_API}/ -d '${owrtToken}'`); | ||
| 223 | if (owrtResult) { | ||
| 224 | console.log(` OpenWRT paid: kind=${owrtResult.kind}`); | ||
| 225 | expect([1022, 21023]).toContain(owrtResult.kind); | ||
| 226 | } | ||
| 227 | } else { | ||
| 228 | console.log(` SKIP: wallet empty for OpenWRT`); | ||
| 229 | } | ||
| 230 | }); | ||
| 231 | |||
| 232 | test('7. Side-by-side comparison screenshot', async ({ page }) => { | ||
| 233 | await sleep(2000); | ||
| 234 | |||
| 235 | const esp = runJson(`curl -s ${API_URL}/`); | ||
| 236 | const owrt = runJson(`curl -s ${OPENWRT_API}/`); | ||
| 237 | const ep = esp.tags.find(t => t[0] === 'price_per_step'); | ||
| 238 | const op = owrt.tags.find(t => t[0] === 'price_per_step'); | ||
| 239 | const em = esp.tags.find(t => t[0] === 'metric')[1]; | ||
| 240 | const om = owrt.tags.find(t => t[0] === 'metric')[1]; | ||
| 241 | const es = esp.tags.find(t => t[0] === 'step_size')[1]; | ||
| 242 | const os = owrt.tags.find(t => t[0] === 'step_size')[1]; | ||
| 243 | |||
| 244 | await page.setContent(`<!DOCTYPE html><html><head><style> | ||
| 245 | body{font-family:monospace;background:#0a0a0a;color:#fff;padding:40px} | ||
| 246 | h1{color:#f7931a}h2{color:#888;margin-top:30px} | ||
| 247 | .grid{display:grid;grid-template-columns:1fr 1fr;gap:30px;max-width:900px} | ||
| 248 | .card{background:#1a1a1a;border:1px solid #333;border-radius:12px;padding:24px} | ||
| 249 | .label{color:#888;font-size:12px}.value{color:#f7931a;font-size:18px;font-weight:bold} | ||
| 250 | .tag{background:#252525;padding:8px 12px;border-radius:6px;margin:4px 0;font-size:13px} | ||
| 251 | .ok{color:#4caf50}.diff{color:#f44336}h3{color:#2196f3;margin-top:20px} | ||
| 252 | </style></head><body> | ||
| 253 | <h1>TollGate Interop Report</h1> | ||
| 254 | <div class="grid"> | ||
| 255 | <div class="card"><h2>ESP32-S3</h2> | ||
| 256 | <div class="tag"><span class="label">Price:</span> <span class="value">${ep[2]} ${ep[3]}/step</span></div> | ||
| 257 | <div class="tag"><span class="label">Metric:</span> <span class="value">${em}</span></div> | ||
| 258 | <div class="tag"><span class="label">Step:</span> <span class="value">${es}</span></div> | ||
| 259 | <div class="tag"><span class="label">Mint:</span> ${ep[4]}</div> | ||
| 260 | </div> | ||
| 261 | <div class="card"><h2>OpenWRT</h2> | ||
| 262 | <div class="tag"><span class="label">Price:</span> <span class="value">${op[2]} ${op[3]}/step</span></div> | ||
| 263 | <div class="tag"><span class="label">Metric:</span> <span class="value">${om}</span></div> | ||
| 264 | <div class="tag"><span class="label">Step:</span> <span class="value">${os}</span></div> | ||
| 265 | <div class="tag"><span class="label">Mint:</span> ${op[4]}</div> | ||
| 266 | </div> | ||
| 267 | </div> | ||
| 268 | <h3>Protocol Compatibility</h3> | ||
| 269 | <div class="tag ok">kind=10021 discovery: Both</div> | ||
| 270 | <div class="tag ok">kind=21023 error: Both</div> | ||
| 271 | <div class="tag ok">kind=1022 session: Both</div> | ||
| 272 | <div class="tag ${em !== om ? 'diff' : 'ok'}">Metric: ${em} vs ${om}</div> | ||
| 273 | </body></html>`); | ||
| 274 | |||
| 275 | await page.screenshot({ path: 'test-results/interop-comparison.png', fullPage: true }); | ||
| 276 | }); | ||
| 277 | }); | ||