From a7d0a672d59bf8985a6fc0e61b49015fabd96513 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 15 May 2026 17:03:40 +0530 Subject: Phase 1 working: captive portal, DNS hijack, NAT-based access control - Fix WiFi init order: netif creation before esp_wifi_init, set mode before set_config - Replace broken netif input filter with NAPT on/off per authentication state - NAPT disabled by default, enabled when client granted, disabled on revoke - Fix test helpers: use -I wlp59s0 for ping, handle nslookup exit code 1 - All 20 API tests pass, all 6 smoke tests pass --- tests/api.mjs | 79 ++++++++++++++++++++++++++++++++++++++ tests/captive-portal.spec.mjs | 75 ++++++++++++++++++++++++++++++++++++ tests/helpers/network.mjs | 89 +++++++++++++++++++++++++++++++++++++++++++ tests/helpers/serial.mjs | 82 +++++++++++++++++++++++++++++++++++++++ tests/network.mjs | 66 ++++++++++++++++++++++++++++++++ tests/playwright.config.mjs | 16 ++++++++ tests/smoke.mjs | 52 +++++++++++++++++++++++++ 7 files changed, 459 insertions(+) create mode 100644 tests/api.mjs create mode 100644 tests/captive-portal.spec.mjs create mode 100644 tests/helpers/network.mjs create mode 100644 tests/helpers/serial.mjs create mode 100644 tests/network.mjs create mode 100644 tests/playwright.config.mjs create mode 100644 tests/smoke.mjs (limited to 'tests') diff --git a/tests/api.mjs b/tests/api.mjs new file mode 100644 index 0000000..5218d7b --- /dev/null +++ b/tests/api.mjs @@ -0,0 +1,79 @@ +import { curl, curlBody, getPortalIP, canPing, canResolve, dnsResolvesToSelf } from './helpers/network.mjs'; + +const IP = getPortalIP(); +let passed = 0, failed = 0; + +function assert(condition, test) { + if (condition) { console.log(` ✓ ${test}`); passed++; } + else { console.log(` ✗ ${test}`); failed++; } +} + +async function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } + +console.log(`\n=== API Tests (target: ${IP}) ===\n`); + +// Test 3: Captive portal serves HTML +console.log('Test 3: GET / returns portal HTML'); +const body3 = curlBody(`http://${IP}/`); +assert(body3 && body3.includes('TollGate'), 'Portal HTML contains "TollGate"'); +assert(body3 && body3.includes('Grant Free Access'), 'Portal has Grant Access button'); + +// Test 4: Captive detection URIs +console.log('\nTest 4: Captive detection URIs'); +for (const uri of ['/generate_204', '/hotspot-detect.html', '/canonical.html', '/success.txt', '/ncsi.txt', '/connecttest.txt', '/wpad.dat', '/redirect']) { + const code = curl(`http://${IP}${uri}`); + assert(code === '200', `${uri} → 200`); +} + +// Test 7: /whoami returns MAC +console.log('\nTest 7: GET /whoami'); +const body7 = curlBody(`http://${IP}/whoami`); +assert(body7 && body7.startsWith('mac='), '/whoami returns mac=...'); + +// Test 8: /usage returns no session +console.log('\nTest 8: GET /usage'); +const body8 = curlBody(`http://${IP}/usage`); +assert(body8 && body8.includes('-1/-1'), '/usage returns -1/-1 before auth'); + +// Test 5: DNS hijack before auth +console.log('\nTest 5: DNS hijack before auth'); +assert(dnsResolvesToSelf('google.com'), 'DNS resolves google.com to AP IP'); + +// Test 6: No internet before auth +console.log('\nTest 6: No internet before auth'); +assert(!canPing('8.8.8.8', 1), 'ping 8.8.8.8 fails before auth'); + +// Test 9: Grant access +console.log('\nTest 9: GET /grant_access'); +const body9 = curlBody(`http://${IP}/grant_access`); +assert(body9 && body9.includes('"granted"'), 'Grant access returns {"status":"granted"}'); + +await sleep(2000); + +// Test 10: DNS forward after auth +console.log('\nTest 10: DNS forward after auth'); +assert(canResolve('google.com'), 'DNS resolves normally after auth'); + +// Test 11: Internet after auth +console.log('\nTest 11: Internet after auth'); +assert(canPing('8.8.8.8'), 'ping 8.8.8.8 succeeds after auth'); + +// Test 12: HTTP browsing works +console.log('\nTest 12: HTTP browsing'); +const body12 = curlBody('http://example.com/'); +assert(body12 && (body12.includes('Example Domain') || body12.includes('example')), 'HTTP page loads'); + +// Test 13: Reset auth +console.log('\nTest 13: GET /reset_authentication'); +const body13 = curlBody(`http://${IP}/reset_authentication`); +assert(body13 && body13.includes('"reset"'), 'Reset returns {"status":"reset"}'); + +await sleep(2000); + +// Test 14: Internet blocked after reset +console.log('\nTest 14: Internet blocked after reset'); +assert(!canPing('8.8.8.8', 1), 'ping fails after auth reset'); + +// Summary +console.log(`\n=== Results: ${passed} passed, ${failed} failed ===\n`); +process.exit(failed > 0 ? 1 : 0); diff --git a/tests/captive-portal.spec.mjs b/tests/captive-portal.spec.mjs new file mode 100644 index 0000000..b6ad96b --- /dev/null +++ b/tests/captive-portal.spec.mjs @@ -0,0 +1,75 @@ +import { test, expect } from '@playwright/test'; + +const PORTAL_IP = process.env.TOLLGATE_IP || '192.168.4.1'; +const PORTAL_URL = `http://${PORTAL_IP}`; + +test.describe('Captive Portal - Phase 1', () => { + + test('portal page loads with TollGate branding', async ({ page }) => { + await page.goto(PORTAL_URL); + await expect(page.locator('h1')).toHaveText('TollGate'); + await expect(page.locator('.subtitle')).toContainText('internet access'); + }); + + test('portal shows price', async ({ page }) => { + await page.goto(PORTAL_URL); + const priceEl = page.locator('.price-amount'); + await expect(priceEl).not.toBeEmpty({ timeout: 5000 }); + }); + + test('grant access button exists', async ({ page }) => { + await page.goto(PORTAL_URL); + const btn = page.locator('#grantBtn'); + await expect(btn).toBeVisible(); + await expect(btn).toHaveText(/Grant Free Access/i); + }); + + test('click grant access shows connected', async ({ page }) => { + await page.goto(PORTAL_URL); + const btn = page.locator('#grantBtn'); + await btn.click(); + const status = page.locator('#status.success'); + await expect(status).toBeVisible({ timeout: 10000 }); + await expect(status).toContainText(/Connected/i); + }); + + test('captive detection URIs return portal', async ({ page }) => { + const uris = ['/generate_204', '/hotspot-detect.html', '/canonical.html', '/success.txt']; + for (const uri of uris) { + const resp = await page.goto(`${PORTAL_URL}${uri}`); + expect(resp.status()).toBe(200); + const body = await resp.text(); + expect(body).toContain('TollGate'); + } + }); + + test('/api/status returns JSON with price', async ({ page }) => { + const resp = await page.goto(`${PORTAL_URL}/api/status`); + expect(resp.status()).toBe(200); + const data = await resp.json(); + expect(data).toHaveProperty('connected'); + expect(data).toHaveProperty('price'); + expect(typeof data.price).toBe('number'); + }); + + test('/whoami returns mac address', async ({ page }) => { + const resp = await page.goto(`${PORTAL_URL}/whoami`); + expect(resp.status()).toBe(200); + const text = await resp.text(); + expect(text).toMatch(/^mac=/); + }); + + test('/usage returns -1/-1 before auth', async ({ page }) => { + const resp = await page.goto(`${PORTAL_URL}/usage`); + expect(resp.status()).toBe(200); + const text = await resp.text(); + expect(text).toBe('-1/-1'); + }); + + test('/reset_authentication works', async ({ page }) => { + const resp = await page.goto(`${PORTAL_URL}/reset_authentication`); + expect(resp.status()).toBe(200); + const data = await resp.json(); + expect(data.status).toBe('reset'); + }); +}); diff --git a/tests/helpers/network.mjs b/tests/helpers/network.mjs new file mode 100644 index 0000000..e4d5086 --- /dev/null +++ b/tests/helpers/network.mjs @@ -0,0 +1,89 @@ +import { execSync } from 'child_process'; + +const ESP32_IP = process.env.TOLLGATE_IP || '192.168.4.1'; +const TIMEOUT = 5000; + +export function curl(args, expectStatus = null) { + const cmd = `curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 --max-time ${TIMEOUT/1000} ${args}`; + try { + const result = execSync(cmd, { encoding: 'utf8', timeout: TIMEOUT + 2000 }).trim(); + if (expectStatus && result !== String(expectStatus)) { + throw new Error(`Expected HTTP ${expectStatus}, got ${result}`); + } + return result; + } catch (e) { + if (e.status === 'ETIMEDOUT' || e.killed) return 'TIMEOUT'; + throw e; + } +} + +export function curlBody(url) { + const cmd = `curl -s --connect-timeout 5 --max-time ${TIMEOUT/1000} "${url}"`; + try { + return execSync(cmd, { encoding: 'utf8', timeout: TIMEOUT + 2000 }); + } catch { + return null; + } +} + +export function getPortalIP() { return ESP32_IP; } + +export function canPing(host = '8.8.8.8', count = 2) { + try { + const result = execSync(`ping -c ${count} -W 2 -I wlp59s0 ${host}`, { encoding: 'utf8', timeout: 10000 }); + return result.includes('0% packet loss') || result.includes('1 packets transmitted'); + } catch { + return false; + } +} + +export function canResolve(domain = 'google.com') { + try { + const result = execSync(`nslookup ${domain} ${ESP32_IP}`, { encoding: 'utf8', timeout: 10000 }); + return result.includes('Address') && !result.includes('NXDOMAIN'); + } catch (e) { + const result = e.stdout || ''; + return result.includes('Address') && !result.includes('NXDOMAIN'); + } +} + +export function dnsResolvesToSelf(domain = 'google.com') { + try { + const result = execSync(`nslookup ${domain} ${ESP32_IP}`, { encoding: 'utf8', timeout: 10000 }); + return result.includes(ESP32_IP); + } catch (e) { + return e.stdout && e.stdout.includes(ESP32_IP); + } +} + +export function connectToAP(ssid, password = '') { + try { + if (password) { + execSync(`nmcli dev wifi connect "${ssid}" password "${password}" ifname wlan0`, { timeout: 30000 }); + } else { + execSync(`nmcli dev wifi connect "${ssid}" ifname wlan0`, { timeout: 30000 }); + } + return true; + } catch { + return false; + } +} + +export function disconnectAP() { + try { + execSync('nmcli dev disconnect wlan0 2>/dev/null || true', { timeout: 10000 }); + return true; + } catch { + return false; + } +} + +export function getWifiInterface() { + try { + const result = execSync('nmcli -t -f DEVICE,TYPE dev status', { encoding: 'utf8' }); + const line = result.split('\n').find(l => l.includes('wifi')); + return line ? line.split(':')[0] : null; + } catch { + return null; + } +} diff --git a/tests/helpers/serial.mjs b/tests/helpers/serial.mjs new file mode 100644 index 0000000..306b552 --- /dev/null +++ b/tests/helpers/serial.mjs @@ -0,0 +1,82 @@ +import { SerialPort } from 'serialport'; +import { ReadlineParser } from '@serialport/parser-readline'; +import { execSync } from 'child_process'; + +const DEFAULT_BAUD = 115200; +const BOOT_TIMEOUT = 30000; + +export async function execSerial(portPath, command, timeoutMs = 5000) { + return new Promise((resolve, reject) => { + const port = new SerialPort({ path: portPath, baudRate: DEFAULT_BAUD }); + const parser = port.pipe(new ReadlineParser()); + const lines = []; + let resolved = false; + + const timer = setTimeout(() => { + if (!resolved) { resolved = true; port.close(); resolve(lines.join('\n')); } + }, timeoutMs); + + parser.on('data', (line) => { + lines.push(line); + if (line.includes('___END___') && !resolved) { + resolved = true; + clearTimeout(timer); + port.close(); + resolve(lines.join('\n')); + } + }); + + port.on('open', () => { + port.write(command + '\n'); + }); + + port.on('error', (err) => { + if (!resolved) { resolved = true; clearTimeout(timer); reject(err); } + }); + }); +} + +export async function waitForBoot(portPath, timeoutMs = BOOT_TIMEOUT) { + return new Promise((resolve, reject) => { + const port = new SerialPort({ path: portPath, baudRate: DEFAULT_BAUD }); + const parser = port.pipe(new ReadlineParser()); + const timer = setTimeout(() => { + port.close(); + reject(new Error('Boot timeout')); + }, timeoutMs); + + parser.on('data', (line) => { + if (line.includes('TollGate services started') || line.includes('WiFi AP+STA started')) { + clearTimeout(timer); + setTimeout(() => { port.close(); resolve(true); }, 500); + } + }); + + port.on('error', (err) => { + clearTimeout(timer); + reject(err); + }); + }); +} + +export async function readSerial(portPath, durationMs = 3000) { + return new Promise((resolve, reject) => { + const port = new SerialPort({ path: portPath, baudRate: DEFAULT_BAUD }); + const parser = port.pipe(new ReadlineParser()); + const lines = []; + + const timer = setTimeout(() => { + port.close(); + resolve(lines.join('\n')); + }, durationMs); + + parser.on('data', (line) => lines.push(line)); + port.on('error', (err) => { clearTimeout(timer); reject(err); }); + }); +} + +export function resetDevice(portPath) { + try { + execSync(`python3 -m esptool --port ${portPath} run 2>/dev/null`, { timeout: 5000 }); + } catch {} +} diff --git a/tests/network.mjs b/tests/network.mjs new file mode 100644 index 0000000..2d302ef --- /dev/null +++ b/tests/network.mjs @@ -0,0 +1,66 @@ +import { execSync } from 'child_process'; + +const IP = process.env.TOLLGATE_IP || '192.168.4.1'; +let passed = 0, failed = 0; + +function assert(condition, test) { + if (condition) { console.log(` ✓ ${test}`); passed++; } + else { console.log(` ✗ ${test}`); failed++; } +} + +function run(cmd) { + try { return execSync(cmd, { encoding: 'utf8', timeout: 15000 }); } + catch { return null; } +} + +console.log(`\n=== Network Tests (target: ${IP}) ===\n`); + +// Test 1: AP visible in scan +console.log('Test 1: AP visible in scan'); +const scan = run('nmcli -t -f SSID dev wifi list 2>/dev/null'); +assert(scan && scan.includes('TollGate'), 'TollGate SSID visible in WiFi scan'); + +// Test 2: DHCP lease +console.log('\nTest 2: DHCP lease / connectivity'); +const ip_show = run(`ip addr show | grep "inet ${IP.split('.').slice(0,3).join('.')}"`); +assert(ip_show !== null, `Has IP in ${IP.split('.').slice(0,3).join('.')}.* subnet`); + +// Test 5: DNS hijack +console.log('\nTest 5: DNS hijack before auth'); +const ns1 = run(`nslookup random-test.example.com ${IP} 2>/dev/null`); +assert(ns1 && ns1.includes(IP), 'DNS resolves arbitrary domain to AP IP'); + +// Test 6: No internet +console.log('\nTest 6: No internet before auth'); +const ping1 = run('ping -c 1 -W 3 1.1.1.1 2>/dev/null'); +assert(ping1 === null || ping1.includes('100% packet loss'), 'Internet blocked before auth'); + +// Grant access for further tests +console.log('\nGranting access...'); +run(`curl -s http://${IP}/grant_access`); + +import { execSync as exec } from 'child_process'; +await new Promise(r => setTimeout(r, 2000)); + +// Test 10: DNS forward +console.log('Test 10: DNS forward after auth'); +const ns2 = run(`nslookup google.com ${IP} 2>/dev/null`); +assert(ns2 && !ns2.includes(IP) && ns2.includes('Address'), 'DNS resolves to real IPs'); + +// Test 11: Internet +console.log('\nTest 11: Internet after auth'); +const ping2 = run('ping -c 2 -W 3 8.8.8.8'); +assert(ping2 && !ping2.includes('100% packet loss'), 'ping succeeds after auth'); + +// Reset +console.log('\nResetting auth...'); +run(`curl -s http://${IP}/reset_authentication`); +await new Promise(r => setTimeout(r, 2000)); + +// Test 14 +console.log('Test 14: Internet blocked after reset'); +const ping3 = run('ping -c 1 -W 3 8.8.8.8 2>/dev/null'); +assert(ping3 === null || ping3.includes('100% packet loss'), 'Internet blocked after reset'); + +console.log(`\n=== Results: ${passed} passed, ${failed} failed ===\n`); +process.exit(failed > 0 ? 1 : 0); diff --git a/tests/playwright.config.mjs b/tests/playwright.config.mjs new file mode 100644 index 0000000..fee0815 --- /dev/null +++ b/tests/playwright.config.mjs @@ -0,0 +1,16 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: '.', + testMatch: '*.spec.mjs', + timeout: 60000, + retries: 0, + use: { + headless: true, + viewport: { width: 1280, height: 900 }, + screenshot: 'only-on-failure', + trace: 'on-first-retry', + }, + reporter: [['list'], ['html', { open: 'never' }]], + workers: 1, +}); diff --git a/tests/smoke.mjs b/tests/smoke.mjs new file mode 100644 index 0000000..19f96de --- /dev/null +++ b/tests/smoke.mjs @@ -0,0 +1,52 @@ +import { execSync } from 'child_process'; + +const PORT = process.argv[2] || '/dev/ttyACM0'; +const IP = process.env.TOLLGATE_IP || '192.168.4.1'; +const SSID = process.env.AP_SSID || 'TollGate'; + +console.log(`\n=== Smoke Test (30s) ===`); +console.log(`Port: ${PORT}, Portal IP: ${IP}, SSID: ${SSID}\n`); + +let passed = 0, failed = 0; +function assert(cond, msg) { + if (cond) { console.log(` ✓ ${msg}`); passed++; } + else { console.log(` ✗ ${msg}`); failed++; } +} + +function run(cmd) { + try { return execSync(cmd, { encoding: 'utf8', timeout: 10000 }); } + catch { return null; } +} + +// 1. Check AP visible +const scan = run('nmcli -t -f SSID dev wifi list 2>/dev/null'); +assert(scan && scan.includes(SSID), `SSID "${SSID}" visible`); + +// 2. Check we can reach portal +const portal = run(`curl -s --connect-timeout 5 http://${IP}/`); +assert(portal && portal.includes('TollGate'), 'Portal HTML loads'); + +// 3. Grant access +const grant = run(`curl -s http://${IP}/grant_access`); +assert(grant && grant.includes('granted'), 'Grant access works'); + +// Wait for DNS +const sleep = ms => new Promise(r => setTimeout(r, ms)); +await sleep(2000); + +// 4. Internet works +const ping = run('ping -c 1 -W 3 -I wlp59s0 1.1.1.1 2>/dev/null'); +assert(ping && !ping.includes('100% packet loss'), 'Internet works after grant'); + +// 5. Reset +const reset = run(`curl -s http://${IP}/reset_authentication`); +assert(reset && reset.includes('reset'), 'Reset auth works'); + +await sleep(2000); + +// 6. Internet blocked +const ping2 = run('ping -c 1 -W 3 -I wlp59s0 1.1.1.1 2>/dev/null'); +assert(!ping2 || ping2.includes('100% packet loss'), 'Internet blocked after reset'); + +console.log(`\n=== Smoke: ${passed} passed, ${failed} failed ===\n`); +process.exit(failed > 0 ? 1 : 0); -- cgit v1.2.3