upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/api.mjs79
-rw-r--r--tests/captive-portal.spec.mjs75
-rw-r--r--tests/helpers/network.mjs89
-rw-r--r--tests/helpers/serial.mjs82
-rw-r--r--tests/network.mjs66
-rw-r--r--tests/playwright.config.mjs16
-rw-r--r--tests/smoke.mjs52
7 files changed, 459 insertions, 0 deletions
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 @@
1import { curl, curlBody, getPortalIP, canPing, canResolve, dnsResolvesToSelf } from './helpers/network.mjs';
2
3const IP = getPortalIP();
4let passed = 0, failed = 0;
5
6function assert(condition, test) {
7 if (condition) { console.log(` ✓ ${test}`); passed++; }
8 else { console.log(` ✗ ${test}`); failed++; }
9}
10
11async function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
12
13console.log(`\n=== API Tests (target: ${IP}) ===\n`);
14
15// Test 3: Captive portal serves HTML
16console.log('Test 3: GET / returns portal HTML');
17const body3 = curlBody(`http://${IP}/`);
18assert(body3 && body3.includes('TollGate'), 'Portal HTML contains "TollGate"');
19assert(body3 && body3.includes('Grant Free Access'), 'Portal has Grant Access button');
20
21// Test 4: Captive detection URIs
22console.log('\nTest 4: Captive detection URIs');
23for (const uri of ['/generate_204', '/hotspot-detect.html', '/canonical.html', '/success.txt', '/ncsi.txt', '/connecttest.txt', '/wpad.dat', '/redirect']) {
24 const code = curl(`http://${IP}${uri}`);
25 assert(code === '200', `${uri} → 200`);
26}
27
28// Test 7: /whoami returns MAC
29console.log('\nTest 7: GET /whoami');
30const body7 = curlBody(`http://${IP}/whoami`);
31assert(body7 && body7.startsWith('mac='), '/whoami returns mac=...');
32
33// Test 8: /usage returns no session
34console.log('\nTest 8: GET /usage');
35const body8 = curlBody(`http://${IP}/usage`);
36assert(body8 && body8.includes('-1/-1'), '/usage returns -1/-1 before auth');
37
38// Test 5: DNS hijack before auth
39console.log('\nTest 5: DNS hijack before auth');
40assert(dnsResolvesToSelf('google.com'), 'DNS resolves google.com to AP IP');
41
42// Test 6: No internet before auth
43console.log('\nTest 6: No internet before auth');
44assert(!canPing('8.8.8.8', 1), 'ping 8.8.8.8 fails before auth');
45
46// Test 9: Grant access
47console.log('\nTest 9: GET /grant_access');
48const body9 = curlBody(`http://${IP}/grant_access`);
49assert(body9 && body9.includes('"granted"'), 'Grant access returns {"status":"granted"}');
50
51await sleep(2000);
52
53// Test 10: DNS forward after auth
54console.log('\nTest 10: DNS forward after auth');
55assert(canResolve('google.com'), 'DNS resolves normally after auth');
56
57// Test 11: Internet after auth
58console.log('\nTest 11: Internet after auth');
59assert(canPing('8.8.8.8'), 'ping 8.8.8.8 succeeds after auth');
60
61// Test 12: HTTP browsing works
62console.log('\nTest 12: HTTP browsing');
63const body12 = curlBody('http://example.com/');
64assert(body12 && (body12.includes('Example Domain') || body12.includes('example')), 'HTTP page loads');
65
66// Test 13: Reset auth
67console.log('\nTest 13: GET /reset_authentication');
68const body13 = curlBody(`http://${IP}/reset_authentication`);
69assert(body13 && body13.includes('"reset"'), 'Reset returns {"status":"reset"}');
70
71await sleep(2000);
72
73// Test 14: Internet blocked after reset
74console.log('\nTest 14: Internet blocked after reset');
75assert(!canPing('8.8.8.8', 1), 'ping fails after auth reset');
76
77// Summary
78console.log(`\n=== Results: ${passed} passed, ${failed} failed ===\n`);
79process.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 @@
1import { test, expect } from '@playwright/test';
2
3const PORTAL_IP = process.env.TOLLGATE_IP || '192.168.4.1';
4const PORTAL_URL = `http://${PORTAL_IP}`;
5
6test.describe('Captive Portal - Phase 1', () => {
7
8 test('portal page loads with TollGate branding', async ({ page }) => {
9 await page.goto(PORTAL_URL);
10 await expect(page.locator('h1')).toHaveText('TollGate');
11 await expect(page.locator('.subtitle')).toContainText('internet access');
12 });
13
14 test('portal shows price', async ({ page }) => {
15 await page.goto(PORTAL_URL);
16 const priceEl = page.locator('.price-amount');
17 await expect(priceEl).not.toBeEmpty({ timeout: 5000 });
18 });
19
20 test('grant access button exists', async ({ page }) => {
21 await page.goto(PORTAL_URL);
22 const btn = page.locator('#grantBtn');
23 await expect(btn).toBeVisible();
24 await expect(btn).toHaveText(/Grant Free Access/i);
25 });
26
27 test('click grant access shows connected', async ({ page }) => {
28 await page.goto(PORTAL_URL);
29 const btn = page.locator('#grantBtn');
30 await btn.click();
31 const status = page.locator('#status.success');
32 await expect(status).toBeVisible({ timeout: 10000 });
33 await expect(status).toContainText(/Connected/i);
34 });
35
36 test('captive detection URIs return portal', async ({ page }) => {
37 const uris = ['/generate_204', '/hotspot-detect.html', '/canonical.html', '/success.txt'];
38 for (const uri of uris) {
39 const resp = await page.goto(`${PORTAL_URL}${uri}`);
40 expect(resp.status()).toBe(200);
41 const body = await resp.text();
42 expect(body).toContain('TollGate');
43 }
44 });
45
46 test('/api/status returns JSON with price', async ({ page }) => {
47 const resp = await page.goto(`${PORTAL_URL}/api/status`);
48 expect(resp.status()).toBe(200);
49 const data = await resp.json();
50 expect(data).toHaveProperty('connected');
51 expect(data).toHaveProperty('price');
52 expect(typeof data.price).toBe('number');
53 });
54
55 test('/whoami returns mac address', async ({ page }) => {
56 const resp = await page.goto(`${PORTAL_URL}/whoami`);
57 expect(resp.status()).toBe(200);
58 const text = await resp.text();
59 expect(text).toMatch(/^mac=/);
60 });
61
62 test('/usage returns -1/-1 before auth', async ({ page }) => {
63 const resp = await page.goto(`${PORTAL_URL}/usage`);
64 expect(resp.status()).toBe(200);
65 const text = await resp.text();
66 expect(text).toBe('-1/-1');
67 });
68
69 test('/reset_authentication works', async ({ page }) => {
70 const resp = await page.goto(`${PORTAL_URL}/reset_authentication`);
71 expect(resp.status()).toBe(200);
72 const data = await resp.json();
73 expect(data.status).toBe('reset');
74 });
75});
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 @@
1import { execSync } from 'child_process';
2
3const ESP32_IP = process.env.TOLLGATE_IP || '192.168.4.1';
4const TIMEOUT = 5000;
5
6export function curl(args, expectStatus = null) {
7 const cmd = `curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 --max-time ${TIMEOUT/1000} ${args}`;
8 try {
9 const result = execSync(cmd, { encoding: 'utf8', timeout: TIMEOUT + 2000 }).trim();
10 if (expectStatus && result !== String(expectStatus)) {
11 throw new Error(`Expected HTTP ${expectStatus}, got ${result}`);
12 }
13 return result;
14 } catch (e) {
15 if (e.status === 'ETIMEDOUT' || e.killed) return 'TIMEOUT';
16 throw e;
17 }
18}
19
20export function curlBody(url) {
21 const cmd = `curl -s --connect-timeout 5 --max-time ${TIMEOUT/1000} "${url}"`;
22 try {
23 return execSync(cmd, { encoding: 'utf8', timeout: TIMEOUT + 2000 });
24 } catch {
25 return null;
26 }
27}
28
29export function getPortalIP() { return ESP32_IP; }
30
31export function canPing(host = '8.8.8.8', count = 2) {
32 try {
33 const result = execSync(`ping -c ${count} -W 2 -I wlp59s0 ${host}`, { encoding: 'utf8', timeout: 10000 });
34 return result.includes('0% packet loss') || result.includes('1 packets transmitted');
35 } catch {
36 return false;
37 }
38}
39
40export function canResolve(domain = 'google.com') {
41 try {
42 const result = execSync(`nslookup ${domain} ${ESP32_IP}`, { encoding: 'utf8', timeout: 10000 });
43 return result.includes('Address') && !result.includes('NXDOMAIN');
44 } catch (e) {
45 const result = e.stdout || '';
46 return result.includes('Address') && !result.includes('NXDOMAIN');
47 }
48}
49
50export function dnsResolvesToSelf(domain = 'google.com') {
51 try {
52 const result = execSync(`nslookup ${domain} ${ESP32_IP}`, { encoding: 'utf8', timeout: 10000 });
53 return result.includes(ESP32_IP);
54 } catch (e) {
55 return e.stdout && e.stdout.includes(ESP32_IP);
56 }
57}
58
59export function connectToAP(ssid, password = '') {
60 try {
61 if (password) {
62 execSync(`nmcli dev wifi connect "${ssid}" password "${password}" ifname wlan0`, { timeout: 30000 });
63 } else {
64 execSync(`nmcli dev wifi connect "${ssid}" ifname wlan0`, { timeout: 30000 });
65 }
66 return true;
67 } catch {
68 return false;
69 }
70}
71
72export function disconnectAP() {
73 try {
74 execSync('nmcli dev disconnect wlan0 2>/dev/null || true', { timeout: 10000 });
75 return true;
76 } catch {
77 return false;
78 }
79}
80
81export function getWifiInterface() {
82 try {
83 const result = execSync('nmcli -t -f DEVICE,TYPE dev status', { encoding: 'utf8' });
84 const line = result.split('\n').find(l => l.includes('wifi'));
85 return line ? line.split(':')[0] : null;
86 } catch {
87 return null;
88 }
89}
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 @@
1import { SerialPort } from 'serialport';
2import { ReadlineParser } from '@serialport/parser-readline';
3import { execSync } from 'child_process';
4
5const DEFAULT_BAUD = 115200;
6const BOOT_TIMEOUT = 30000;
7
8export async function execSerial(portPath, command, timeoutMs = 5000) {
9 return new Promise((resolve, reject) => {
10 const port = new SerialPort({ path: portPath, baudRate: DEFAULT_BAUD });
11 const parser = port.pipe(new ReadlineParser());
12 const lines = [];
13 let resolved = false;
14
15 const timer = setTimeout(() => {
16 if (!resolved) { resolved = true; port.close(); resolve(lines.join('\n')); }
17 }, timeoutMs);
18
19 parser.on('data', (line) => {
20 lines.push(line);
21 if (line.includes('___END___') && !resolved) {
22 resolved = true;
23 clearTimeout(timer);
24 port.close();
25 resolve(lines.join('\n'));
26 }
27 });
28
29 port.on('open', () => {
30 port.write(command + '\n');
31 });
32
33 port.on('error', (err) => {
34 if (!resolved) { resolved = true; clearTimeout(timer); reject(err); }
35 });
36 });
37}
38
39export async function waitForBoot(portPath, timeoutMs = BOOT_TIMEOUT) {
40 return new Promise((resolve, reject) => {
41 const port = new SerialPort({ path: portPath, baudRate: DEFAULT_BAUD });
42 const parser = port.pipe(new ReadlineParser());
43 const timer = setTimeout(() => {
44 port.close();
45 reject(new Error('Boot timeout'));
46 }, timeoutMs);
47
48 parser.on('data', (line) => {
49 if (line.includes('TollGate services started') || line.includes('WiFi AP+STA started')) {
50 clearTimeout(timer);
51 setTimeout(() => { port.close(); resolve(true); }, 500);
52 }
53 });
54
55 port.on('error', (err) => {
56 clearTimeout(timer);
57 reject(err);
58 });
59 });
60}
61
62export async function readSerial(portPath, durationMs = 3000) {
63 return new Promise((resolve, reject) => {
64 const port = new SerialPort({ path: portPath, baudRate: DEFAULT_BAUD });
65 const parser = port.pipe(new ReadlineParser());
66 const lines = [];
67
68 const timer = setTimeout(() => {
69 port.close();
70 resolve(lines.join('\n'));
71 }, durationMs);
72
73 parser.on('data', (line) => lines.push(line));
74 port.on('error', (err) => { clearTimeout(timer); reject(err); });
75 });
76}
77
78export function resetDevice(portPath) {
79 try {
80 execSync(`python3 -m esptool --port ${portPath} run 2>/dev/null`, { timeout: 5000 });
81 } catch {}
82}
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 @@
1import { execSync } from 'child_process';
2
3const IP = process.env.TOLLGATE_IP || '192.168.4.1';
4let passed = 0, failed = 0;
5
6function assert(condition, test) {
7 if (condition) { console.log(` ✓ ${test}`); passed++; }
8 else { console.log(` ✗ ${test}`); failed++; }
9}
10
11function run(cmd) {
12 try { return execSync(cmd, { encoding: 'utf8', timeout: 15000 }); }
13 catch { return null; }
14}
15
16console.log(`\n=== Network Tests (target: ${IP}) ===\n`);
17
18// Test 1: AP visible in scan
19console.log('Test 1: AP visible in scan');
20const scan = run('nmcli -t -f SSID dev wifi list 2>/dev/null');
21assert(scan && scan.includes('TollGate'), 'TollGate SSID visible in WiFi scan');
22
23// Test 2: DHCP lease
24console.log('\nTest 2: DHCP lease / connectivity');
25const ip_show = run(`ip addr show | grep "inet ${IP.split('.').slice(0,3).join('.')}"`);
26assert(ip_show !== null, `Has IP in ${IP.split('.').slice(0,3).join('.')}.* subnet`);
27
28// Test 5: DNS hijack
29console.log('\nTest 5: DNS hijack before auth');
30const ns1 = run(`nslookup random-test.example.com ${IP} 2>/dev/null`);
31assert(ns1 && ns1.includes(IP), 'DNS resolves arbitrary domain to AP IP');
32
33// Test 6: No internet
34console.log('\nTest 6: No internet before auth');
35const ping1 = run('ping -c 1 -W 3 1.1.1.1 2>/dev/null');
36assert(ping1 === null || ping1.includes('100% packet loss'), 'Internet blocked before auth');
37
38// Grant access for further tests
39console.log('\nGranting access...');
40run(`curl -s http://${IP}/grant_access`);
41
42import { execSync as exec } from 'child_process';
43await new Promise(r => setTimeout(r, 2000));
44
45// Test 10: DNS forward
46console.log('Test 10: DNS forward after auth');
47const ns2 = run(`nslookup google.com ${IP} 2>/dev/null`);
48assert(ns2 && !ns2.includes(IP) && ns2.includes('Address'), 'DNS resolves to real IPs');
49
50// Test 11: Internet
51console.log('\nTest 11: Internet after auth');
52const ping2 = run('ping -c 2 -W 3 8.8.8.8');
53assert(ping2 && !ping2.includes('100% packet loss'), 'ping succeeds after auth');
54
55// Reset
56console.log('\nResetting auth...');
57run(`curl -s http://${IP}/reset_authentication`);
58await new Promise(r => setTimeout(r, 2000));
59
60// Test 14
61console.log('Test 14: Internet blocked after reset');
62const ping3 = run('ping -c 1 -W 3 8.8.8.8 2>/dev/null');
63assert(ping3 === null || ping3.includes('100% packet loss'), 'Internet blocked after reset');
64
65console.log(`\n=== Results: ${passed} passed, ${failed} failed ===\n`);
66process.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 @@
1import { defineConfig } from '@playwright/test';
2
3export default defineConfig({
4 testDir: '.',
5 testMatch: '*.spec.mjs',
6 timeout: 60000,
7 retries: 0,
8 use: {
9 headless: true,
10 viewport: { width: 1280, height: 900 },
11 screenshot: 'only-on-failure',
12 trace: 'on-first-retry',
13 },
14 reporter: [['list'], ['html', { open: 'never' }]],
15 workers: 1,
16});
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 @@
1import { execSync } from 'child_process';
2
3const PORT = process.argv[2] || '/dev/ttyACM0';
4const IP = process.env.TOLLGATE_IP || '192.168.4.1';
5const SSID = process.env.AP_SSID || 'TollGate';
6
7console.log(`\n=== Smoke Test (30s) ===`);
8console.log(`Port: ${PORT}, Portal IP: ${IP}, SSID: ${SSID}\n`);
9
10let passed = 0, failed = 0;
11function assert(cond, msg) {
12 if (cond) { console.log(` ✓ ${msg}`); passed++; }
13 else { console.log(` ✗ ${msg}`); failed++; }
14}
15
16function run(cmd) {
17 try { return execSync(cmd, { encoding: 'utf8', timeout: 10000 }); }
18 catch { return null; }
19}
20
21// 1. Check AP visible
22const scan = run('nmcli -t -f SSID dev wifi list 2>/dev/null');
23assert(scan && scan.includes(SSID), `SSID "${SSID}" visible`);
24
25// 2. Check we can reach portal
26const portal = run(`curl -s --connect-timeout 5 http://${IP}/`);
27assert(portal && portal.includes('TollGate'), 'Portal HTML loads');
28
29// 3. Grant access
30const grant = run(`curl -s http://${IP}/grant_access`);
31assert(grant && grant.includes('granted'), 'Grant access works');
32
33// Wait for DNS
34const sleep = ms => new Promise(r => setTimeout(r, ms));
35await sleep(2000);
36
37// 4. Internet works
38const ping = run('ping -c 1 -W 3 -I wlp59s0 1.1.1.1 2>/dev/null');
39assert(ping && !ping.includes('100% packet loss'), 'Internet works after grant');
40
41// 5. Reset
42const reset = run(`curl -s http://${IP}/reset_authentication`);
43assert(reset && reset.includes('reset'), 'Reset auth works');
44
45await sleep(2000);
46
47// 6. Internet blocked
48const ping2 = run('ping -c 1 -W 3 -I wlp59s0 1.1.1.1 2>/dev/null');
49assert(!ping2 || ping2.includes('100% packet loss'), 'Internet blocked after reset');
50
51console.log(`\n=== Smoke: ${passed} passed, ${failed} failed ===\n`);
52process.exit(failed > 0 ? 1 : 0);