upleb.uk

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

summaryrefslogtreecommitdiff
path: root/tests/e2e
diff options
context:
space:
mode:
authorYour Name <you@example.com>2026-05-17 17:18:43 +0530
committerYour Name <you@example.com>2026-05-17 17:18:43 +0530
commit8071741815f0b0938701e80a63e80b0ec94b2778 (patch)
tree2a1511480e0b58f4efb144aa9d10c9fba5eed034 /tests/e2e
parent0c2c67b463d6a90aaa0bb69bf3c91dba1d9ec3ec (diff)
refactor: reorganize test suite, add integration tests for NAT filter
- Move integration tests (api, network, phase2, smoke) to tests/integration/ - Move Playwright specs (captive-portal, interop-happy-path) to tests/e2e/ - Move playwright.config.mjs to tests/e2e/ - Fix hardcoded IP fallbacks: 192.168.4.1 → 10.192.45.1 - Add test-reset-auth.mjs: reset→pay→allow→revoke→block cycle - Add test-session-expiry.mjs: pay→wait 65s→verify blocked (slow test) - Add test-dns-firewall.mjs: DNS hijack/forward + per-client NAT filter - Update Makefile with test-unit, test-integration, test-e2e, test-all targets - Update package.json scripts for new paths - Fix Playwright video: retain-on-failure instead of always-on - Update AGENTS.md: per-client NAT filter description - Update CHECKLIST.md: mark completed items, add Board B identity - Board B nsec: 9af47906... → SSID TollGate-b96d80, AP IP 10.185.47.1 - 186 unit tests passing
Diffstat (limited to 'tests/e2e')
-rw-r--r--tests/e2e/captive-portal.spec.mjs118
-rw-r--r--tests/e2e/interop-happy-path.spec.mjs277
-rw-r--r--tests/e2e/playwright.config.mjs18
3 files changed, 413 insertions, 0 deletions
diff --git a/tests/e2e/captive-portal.spec.mjs b/tests/e2e/captive-portal.spec.mjs
new file mode 100644
index 0000000..ab9d4f1
--- /dev/null
+++ b/tests/e2e/captive-portal.spec.mjs
@@ -0,0 +1,118 @@
1import { test, expect } from '@playwright/test';
2
3const PORTAL_IP = process.env.TOLLGATE_IP || '10.192.45.1';
4const PORTAL_URL = `http://${PORTAL_IP}`;
5const API_URL = `http://${PORTAL_IP}:2121`;
6
7test.describe('Captive Portal - Phase 2', () => {
8
9 test('portal page loads with TollGate branding', async ({ page }) => {
10 await page.goto(PORTAL_URL);
11 await expect(page.locator('h1')).toHaveText('TollGate');
12 await expect(page.locator('.subtitle')).toContainText('internet access');
13 });
14
15 test('portal shows price from API', async ({ page }) => {
16 await page.goto(PORTAL_URL);
17 const priceEl = page.locator('.price-amount');
18 await expect(priceEl).toHaveText(/\d+/, { timeout: 5000 });
19 });
20
21 test('portal embeds mint URL without JavaScript fetch', async ({ request }) => {
22 const resp = await request.fetch(PORTAL_URL);
23 const body = await resp.text();
24 expect(body).not.toContain('Loading...');
25 expect(body).not.toContain('Error loading mint URL');
26 expect(body).toMatch(/testnut\.cashu\.space/);
27 });
28
29 test('portal embeds price without JavaScript fetch', async ({ request }) => {
30 const resp = await request.fetch(PORTAL_URL);
31 const body = await resp.text();
32 expect(body).not.toContain('__PRICE__');
33 expect(body).toMatch(/price-amount['"]>\d+</);
34 });
35
36 test('portal HTML has no unresolved template placeholders', async ({ request }) => {
37 const resp = await request.fetch(PORTAL_URL);
38 const body = await resp.text();
39 expect(body).not.toContain('__AP_IP__');
40 expect(body).not.toContain('__MINT_URL__');
41 expect(body).not.toContain('__PRICE__');
42 });
43
44 test('mints section appears after token input in DOM order', async ({ page }) => {
45 await page.goto(PORTAL_URL);
46 const textarea = page.locator('#tokenInput');
47 const mintUrl = page.locator('#mintUrl');
48 await expect(textarea).toBeVisible();
49 await expect(mintUrl).toBeVisible();
50 const inputBox = await textarea.boundingBox();
51 const mintBox = await mintUrl.boundingBox();
52 expect(mintBox.y).toBeGreaterThan(inputBox.y);
53 });
54
55 test('portal has Cashu token input', async ({ page }) => {
56 await page.goto(PORTAL_URL);
57 const textarea = page.locator('#tokenInput');
58 await expect(textarea).toBeVisible();
59 await expect(textarea).toHaveAttribute('placeholder', /cashuA/);
60 });
61
62 test('portal has Pay & Connect button', async ({ page }) => {
63 await page.goto(PORTAL_URL);
64 const btn = page.locator('#payBtn');
65 await expect(btn).toBeVisible();
66 await expect(btn).toHaveText(/Pay/);
67 });
68
69 test('captive detection URIs return portal HTML (200)', async ({ request }) => {
70 const uris = ['/generate_204', '/hotspot-detect.html', '/canonical.html', '/success.txt'];
71 for (const uri of uris) {
72 const resp = await request.fetch(`${PORTAL_URL}${uri}`);
73 expect(resp.status()).toBe(200);
74 const body = await resp.text();
75 expect(body).toContain('TollGate');
76 }
77 });
78
79 test('catch-all URIs redirect to portal page', async ({ page }) => {
80 await page.goto(`${PORTAL_URL}/some-random-page`);
81 await expect(page.locator('h1')).toHaveText('TollGate');
82 });
83
84 test('/whoami returns ip and mac', async ({ page }) => {
85 const resp = await page.goto(`${API_URL}/whoami`);
86 expect(resp.status()).toBe(200);
87 const text = await resp.text();
88 expect(text).toMatch(/ip=\d+\.\d+\.\d+\.\d+/);
89 expect(text).toMatch(/mac=(unknown|[0-9a-f]{2}:)/);
90 });
91
92 test('/usage returns -1/-1 before payment', async ({ page }) => {
93 const resp = await page.goto(`${API_URL}/usage`);
94 expect(resp.status()).toBe(200);
95 const text = await resp.text();
96 expect(text).toBe('-1/-1');
97 });
98
99 test('API advertisement has correct structure', async ({ page }) => {
100 const resp = await page.goto(API_URL);
101 expect(resp.status()).toBe(200);
102 const data = await resp.json();
103 expect(data.kind).toBe(10021);
104 expect(data.tags).toBeDefined();
105 expect(data.tags.some(t => t[0] === 'price_per_step')).toBe(true);
106 expect(data.tags.some(t => t[0] === 'step_size')).toBe(true);
107 });
108
109 test('invalid token returns error', async ({ request }) => {
110 const resp = await request.fetch(API_URL, {
111 method: 'POST',
112 data: 'garbage_not_a_token'
113 });
114 expect(resp.status()).toBe(400);
115 const data = await resp.json();
116 expect(data.kind).toBe(21023);
117 });
118});
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 @@
1import { test, expect } from '@playwright/test';
2import { execSync } from 'child_process';
3
4const PORTAL_IP = process.env.TOLLGATE_IP || '10.192.45.1';
5const PORTAL_URL = `http://${PORTAL_IP}`;
6const API_URL = `http://${PORTAL_IP}:2121`;
7const OPENWRT_IP = process.env.OPENWRT_IP || '10.47.41.1';
8const OPENWRT_API = `http://${OPENWRT_IP}:2121`;
9
10function run(cmd) {
11 try { return execSync(cmd, { encoding: 'utf8', timeout: 15000 }); }
12 catch (e) { return e.stdout || null; }
13}
14
15function runJson(cmd) {
16 const out = run(cmd);
17 try { return out ? JSON.parse(out) : null; }
18 catch { return null; }
19}
20
21function 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
27function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
28
29test.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
150test.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});
diff --git a/tests/e2e/playwright.config.mjs b/tests/e2e/playwright.config.mjs
new file mode 100644
index 0000000..f4cbe01
--- /dev/null
+++ b/tests/e2e/playwright.config.mjs
@@ -0,0 +1,18 @@
1import { defineConfig } from '@playwright/test';
2
3export default defineConfig({
4 testDir: '.',
5 testMatch: '*.spec.mjs',
6 timeout: 120000,
7 retries: 0,
8 use: {
9 headless: true,
10 viewport: { width: 1280, height: 900 },
11 screenshot: 'on',
12 video: 'retain-on-failure',
13 trace: 'on-first-retry',
14 },
15 reporter: [['list'], ['html', { open: 'never' }]],
16 outputDir: 'test-results',
17 workers: 1,
18});