diff options
| author | Your Name <you@example.com> | 2026-05-16 04:51:57 +0530 |
|---|---|---|
| committer | Your Name <you@example.com> | 2026-05-16 04:51:57 +0530 |
| commit | ee4e13680f522253f94e8ebdea5df80332afc495 (patch) | |
| tree | eb3270f5be079067b07de15b9af094c30582c68c | |
| parent | 3063dea143b576792e5831421e5607cbd60d6816 (diff) | |
Phase 2 Playwright tests: 10/10 passing (portal, captive detection, API)
- Updated from Phase 1 tests to Phase 2 (302 redirects, Cashu token input)
- Test captive detection URIs return 302 (using request API)
- Test invalid token via request API (no CORS issues)
- Tests: portal branding, price, token input, pay button, detection redirects
- Tests: whoami, usage, API advertisement, invalid token
| -rw-r--r-- | CHECKLIST.md | 6 | ||||
| -rw-r--r-- | PLAN.md | 4 | ||||
| -rw-r--r-- | tests/captive-portal.spec.mjs | 73 |
3 files changed, 46 insertions, 37 deletions
diff --git a/CHECKLIST.md b/CHECKLIST.md index dc16b0b..2ac593c 100644 --- a/CHECKLIST.md +++ b/CHECKLIST.md | |||
| @@ -58,12 +58,12 @@ | |||
| 58 | - [x] Test 19: Invalid token rejected (POST garbage → 400, kind=21023) — PASSING | 58 | - [x] Test 19: Invalid token rejected (POST garbage → 400, kind=21023) — PASSING |
| 59 | - [x] Test 20: Spent token rejected (reuse token → kind=21023) — PASSING | 59 | - [x] Test 20: Spent token rejected (reuse token → kind=21023) — PASSING |
| 60 | - [x] Test 21: Wrong mint rejected (POST token from wrong mint → kind=21023) — PASSING | 60 | - [x] Test 21: Wrong mint rejected (POST token from wrong mint → kind=21023) — PASSING |
| 61 | - [x] Test 22: Session expiry (wait for allotment → internet blocked) — PASSING | ||
| 62 | - [x] Test 23: Session renewal (second payment → allotment extended) — PASSING | ||
| 61 | - [x] Test: /whoami returns ip=X.X.X.X mac=XX:XX:XX:XX:XX:XX — PASSING | 63 | - [x] Test: /whoami returns ip=X.X.X.X mac=XX:XX:XX:XX:XX:XX — PASSING |
| 62 | - [x] Test: Portal has payment form (Cashu token input + Pay button) — PASSING | 64 | - [x] Test: Portal has payment form (Cashu token input + Pay button) — PASSING |
| 63 | 65 | ||
| 64 | ### Tests Not Yet Run (need hardware + time) | 66 | ### Tests Not Yet Run (need Playwright) |
| 65 | - [ ] Test 22: Session expiry (wait for allotment → internet blocked) | ||
| 66 | - [ ] Test 23: Session renewal (second payment → allotment extended) | ||
| 67 | - [ ] Test 24: Portal payment form visible in browser (Playwright) | 67 | - [ ] Test 24: Portal payment form visible in browser (Playwright) |
| 68 | - [ ] Test 25: Two clients pay independently | 68 | - [ ] Test 25: Two clients pay independently |
| 69 | - [ ] Test 26: Client isolation (only payer gets internet) | 69 | - [ ] Test 26: Client isolation (only payer gets internet) |
| @@ -72,8 +72,8 @@ Build a TollGate firmware for two ESP32 devices, following the [TollGate protoco | |||
| 72 | | 19 | Invalid token | POST :2121/ garbage | kind=21023 error | PASS | | 72 | | 19 | Invalid token | POST :2121/ garbage | kind=21023 error | PASS | |
| 73 | | 20 | Spent token | Reuse token | kind=21023 spent error | PASS | | 73 | | 20 | Spent token | Reuse token | kind=21023 spent error | PASS | |
| 74 | | 21 | Wrong mint | Token from unaccepted mint | kind=21023 mint error | PASS | | 74 | | 21 | Wrong mint | Token from unaccepted mint | kind=21023 mint error | PASS | |
| 75 | | 22 | Session expiry | Wait for allotment | Internet blocked | TODO | | 75 | | 22 | Session expiry | Wait for allotment | Internet blocked | PASS | |
| 76 | | 23 | Session renewal | Second payment | Allotment extended | TODO | | 76 | | 23 | Session renewal | Second payment | Allotment extended | PASS | |
| 77 | | 24 | Portal payment form | Playwright paste token | Checkmark shown | TODO | | 77 | | 24 | Portal payment form | Playwright paste token | Checkmark shown | TODO | |
| 78 | | 25 | Two clients pay independently | Two POSTs | Both authenticated | TODO | | 78 | | 25 | Two clients pay independently | Two POSTs | Both authenticated | TODO | |
| 79 | | 26 | Client isolation | Only payer gets internet | Non-payer blocked | TODO | | 79 | | 26 | Client isolation | Only payer gets internet | Non-payer blocked | TODO | |
diff --git a/tests/captive-portal.spec.mjs b/tests/captive-portal.spec.mjs index b6ad96b..acd2a40 100644 --- a/tests/captive-portal.spec.mjs +++ b/tests/captive-portal.spec.mjs | |||
| @@ -2,8 +2,9 @@ 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 || '192.168.4.1'; |
| 4 | const PORTAL_URL = `http://${PORTAL_IP}`; | 4 | const PORTAL_URL = `http://${PORTAL_IP}`; |
| 5 | const API_URL = `http://${PORTAL_IP}:2121`; | ||
| 5 | 6 | ||
| 6 | test.describe('Captive Portal - Phase 1', () => { | 7 | test.describe('Captive Portal - Phase 2', () => { |
| 7 | 8 | ||
| 8 | test('portal page loads with TollGate branding', async ({ page }) => { | 9 | test('portal page loads with TollGate branding', async ({ page }) => { |
| 9 | await page.goto(PORTAL_URL); | 10 | await page.goto(PORTAL_URL); |
| @@ -11,65 +12,73 @@ test.describe('Captive Portal - Phase 1', () => { | |||
| 11 | await expect(page.locator('.subtitle')).toContainText('internet access'); | 12 | await expect(page.locator('.subtitle')).toContainText('internet access'); |
| 12 | }); | 13 | }); |
| 13 | 14 | ||
| 14 | test('portal shows price', async ({ page }) => { | 15 | test('portal shows price from API', async ({ page }) => { |
| 15 | await page.goto(PORTAL_URL); | 16 | await page.goto(PORTAL_URL); |
| 16 | const priceEl = page.locator('.price-amount'); | 17 | const priceEl = page.locator('.price-amount'); |
| 17 | await expect(priceEl).not.toBeEmpty({ timeout: 5000 }); | 18 | await expect(priceEl).not.toBeEmpty({ timeout: 5000 }); |
| 18 | }); | 19 | }); |
| 19 | 20 | ||
| 20 | test('grant access button exists', async ({ page }) => { | 21 | test('portal has Cashu token input', async ({ page }) => { |
| 21 | await page.goto(PORTAL_URL); | 22 | await page.goto(PORTAL_URL); |
| 22 | const btn = page.locator('#grantBtn'); | 23 | const textarea = page.locator('#tokenInput'); |
| 23 | await expect(btn).toBeVisible(); | 24 | await expect(textarea).toBeVisible(); |
| 24 | await expect(btn).toHaveText(/Grant Free Access/i); | 25 | await expect(textarea).toHaveAttribute('placeholder', /cashuA/); |
| 25 | }); | 26 | }); |
| 26 | 27 | ||
| 27 | test('click grant access shows connected', async ({ page }) => { | 28 | test('portal has Pay & Connect button', async ({ page }) => { |
| 28 | await page.goto(PORTAL_URL); | 29 | await page.goto(PORTAL_URL); |
| 29 | const btn = page.locator('#grantBtn'); | 30 | const btn = page.locator('#payBtn'); |
| 30 | await btn.click(); | 31 | await expect(btn).toBeVisible(); |
| 31 | const status = page.locator('#status.success'); | 32 | await expect(btn).toHaveText(/Pay/); |
| 32 | await expect(status).toBeVisible({ timeout: 10000 }); | ||
| 33 | await expect(status).toContainText(/Connected/i); | ||
| 34 | }); | 33 | }); |
| 35 | 34 | ||
| 36 | test('captive detection URIs return portal', async ({ page }) => { | 35 | test('captive detection URIs return 302 redirect', async ({ request }) => { |
| 37 | const uris = ['/generate_204', '/hotspot-detect.html', '/canonical.html', '/success.txt']; | 36 | const uris = ['/generate_204', '/hotspot-detect.html', '/canonical.html', '/success.txt']; |
| 38 | for (const uri of uris) { | 37 | for (const uri of uris) { |
| 39 | const resp = await page.goto(`${PORTAL_URL}${uri}`); | 38 | const resp = await request.fetch(`${PORTAL_URL}${uri}`, { maxRedirects: 0, ignoreHTTPSErrors: true }); |
| 40 | expect(resp.status()).toBe(200); | 39 | expect(resp.status()).toBe(302); |
| 41 | const body = await resp.text(); | 40 | const location = resp.headers()['location']; |
| 42 | expect(body).toContain('TollGate'); | 41 | expect(location).toBe('http://192.168.4.1/'); |
| 43 | } | 42 | } |
| 44 | }); | 43 | }); |
| 45 | 44 | ||
| 46 | test('/api/status returns JSON with price', async ({ page }) => { | 45 | test('captive detection redirects to portal page', async ({ page }) => { |
| 47 | const resp = await page.goto(`${PORTAL_URL}/api/status`); | 46 | await page.goto(`${PORTAL_URL}/generate_204`); |
| 48 | expect(resp.status()).toBe(200); | 47 | await expect(page.locator('h1')).toHaveText('TollGate'); |
| 49 | const data = await resp.json(); | ||
| 50 | expect(data).toHaveProperty('connected'); | ||
| 51 | expect(data).toHaveProperty('price'); | ||
| 52 | expect(typeof data.price).toBe('number'); | ||
| 53 | }); | 48 | }); |
| 54 | 49 | ||
| 55 | test('/whoami returns mac address', async ({ page }) => { | 50 | test('/whoami returns ip and mac', async ({ page }) => { |
| 56 | const resp = await page.goto(`${PORTAL_URL}/whoami`); | 51 | const resp = await page.goto(`${API_URL}/whoami`); |
| 57 | expect(resp.status()).toBe(200); | 52 | expect(resp.status()).toBe(200); |
| 58 | const text = await resp.text(); | 53 | const text = await resp.text(); |
| 59 | expect(text).toMatch(/^mac=/); | 54 | expect(text).toMatch(/ip=\d+\.\d+\.\d+\.\d+/); |
| 55 | expect(text).toMatch(/mac=[0-9a-f]{2}:/); | ||
| 60 | }); | 56 | }); |
| 61 | 57 | ||
| 62 | test('/usage returns -1/-1 before auth', async ({ page }) => { | 58 | test('/usage returns -1/-1 before payment', async ({ page }) => { |
| 63 | const resp = await page.goto(`${PORTAL_URL}/usage`); | 59 | const resp = await page.goto(`${API_URL}/usage`); |
| 64 | expect(resp.status()).toBe(200); | 60 | expect(resp.status()).toBe(200); |
| 65 | const text = await resp.text(); | 61 | const text = await resp.text(); |
| 66 | expect(text).toBe('-1/-1'); | 62 | expect(text).toBe('-1/-1'); |
| 67 | }); | 63 | }); |
| 68 | 64 | ||
| 69 | test('/reset_authentication works', async ({ page }) => { | 65 | test('API advertisement has correct structure', async ({ page }) => { |
| 70 | const resp = await page.goto(`${PORTAL_URL}/reset_authentication`); | 66 | const resp = await page.goto(API_URL); |
| 71 | expect(resp.status()).toBe(200); | 67 | expect(resp.status()).toBe(200); |
| 72 | const data = await resp.json(); | 68 | const data = await resp.json(); |
| 73 | expect(data.status).toBe('reset'); | 69 | expect(data.kind).toBe(10021); |
| 70 | expect(data.tags).toBeDefined(); | ||
| 71 | expect(data.tags.some(t => t[0] === 'price_per_step')).toBe(true); | ||
| 72 | expect(data.tags.some(t => t[0] === 'step_size')).toBe(true); | ||
| 73 | }); | ||
| 74 | |||
| 75 | test('invalid token returns error', async ({ request }) => { | ||
| 76 | const resp = await request.fetch(API_URL, { | ||
| 77 | method: 'POST', | ||
| 78 | data: 'garbage_not_a_token' | ||
| 79 | }); | ||
| 80 | expect(resp.status()).toBe(400); | ||
| 81 | const data = await resp.json(); | ||
| 82 | expect(data.kind).toBe(21023); | ||
| 74 | }); | 83 | }); |
| 75 | }); | 84 | }); |