/** * Acceptance tests for k_phone proxy routing — Chrome inside the Android emulator. * * Same four serial tests as k_phone_proxy.spec.js, but the browser runs inside * the emulator via Playwright's Android module. From inside the emulator * 127.0.0.1:8888 IS Component 1 (filter_proxy.dart) — no adb port-forward needed. * * Prerequisites: * 1. Android emulator running with the k_phone app started. * 2. ADB connected: adb devices shows the emulator. * 3. card_emulator_bridge.py running on the Mac (auto-approves FIDO2 assertions): * uv run --python 3.12 --with fido2 --with cbor2 --with cryptography \ * tests/card_emulator_bridge.py * * Run: * npx playwright test tests/k_phone_android.spec.js * npx playwright test tests/k_phone_android.spec.js --headed # shows emulator Chrome * * Tests skip automatically if no Android device/emulator is found via ADB. * * Env vars: * GATED_URL URL of a gated resource (default: http://httpbin.org/get) * GATED_METHOD HTTP method for gated request (default: GET) * UNGATED_URL URL of a non-gated resource (default: http://example.com) * CARD_REGISTRATION_TIMEOUT_MS (default: 90000) * CARD_LOGIN_TIMEOUT_MS (default: 90000) * * Note on proxy bypass: * Chrome bypasses --proxy-server for 127.0.0.1 / localhost by default. * Portal API calls (127.0.0.1:8771) therefore reach Component 2 directly. * External host requests (httpbin.org, example.com) go through Component 1. */ const { test, expect } = require('@playwright/test'); let android = null; try { android = require('playwright').android; } catch { // playwright not installed separately — tests will be skipped. } const GATED_URL = process.env.GATED_URL || 'http://httpbin.org/get'; const GATED_METHOD = (process.env.GATED_METHOD || 'GET').toUpperCase(); const UNGATED_URL = process.env.UNGATED_URL || 'http://example.com'; const registrationTimeoutMs = Number(process.env.CARD_REGISTRATION_TIMEOUT_MS || '90000'); const cardAssertionTimeoutMs = Number(process.env.CARD_LOGIN_TIMEOUT_MS || '90000'); // Component 2 is always at 127.0.0.1:8771 from inside the emulator. const PORTAL = 'http://127.0.0.1:8771'; function uniqueUsername() { return `pw_${Date.now().toString(36)}`; } // --------------------------------------------------------------------------- // chromeFetch — makes an HTTP request from inside Android Chrome. // // fetch() in the page context uses Chrome's --proxy-server for external hosts // and bypasses the proxy for 127.0.0.1. Returns { status, ok, body }. // --------------------------------------------------------------------------- async function chromeFetch(page, url, { method = 'GET', data = null, timeoutMs = 15_000 } = {}) { return page.evaluate( async ({ url, method, data, timeoutMs }) => { const ctrl = new AbortController(); const tid = setTimeout(() => ctrl.abort(), timeoutMs); try { const opts = { method, signal: ctrl.signal }; if (data !== null) { opts.headers = { 'Content-Type': 'application/json' }; opts.body = JSON.stringify(data); } const r = await fetch(url, opts); clearTimeout(tid); let body = null; try { body = await r.clone().json(); } catch { body = await r.text(); } return { status: r.status, ok: r.ok, body }; } catch (e) { clearTimeout(tid); return { status: 0, ok: false, error: e.message }; } }, { url, method, data, timeoutMs }, ); } // --------------------------------------------------------------------------- // Suite // --------------------------------------------------------------------------- test.describe.serial('k_phone proxy routing — Android Chrome', () => { let device = null; let proxyCtx = null; // Chrome launched with --proxy-server=127.0.0.1:8888 let skipReason = null; let enrolledUser = null; test.beforeAll(async () => { if (!android) { skipReason = 'playwright package not found — run: npm install playwright'; return; } try { const devices = await android.devices(); if (!devices.length) { skipReason = 'No Android emulator connected — run: adb devices'; return; } device = devices[0]; // Launch Chrome inside the emulator. // --proxy-server points at Component 1 on the emulator's own loopback. proxyCtx = await device.launchBrowser({ args: ['--proxy-server=127.0.0.1:8888'], }); // Clean state: delete any users left from previous runs. // 127.0.0.1 bypasses the proxy, so these calls reach Component 2 directly. const page = await proxyCtx.newPage(); const list = await chromeFetch(page, `${PORTAL}/enroll/list`); for (const u of list.body?.users ?? []) { await chromeFetch(page, `${PORTAL}/enroll/delete`, { method: 'POST', data: { username: u.username }, }); } await page.close(); } catch (e) { skipReason = `Android setup failed: ${e.message}`; } }); // Skip every test in the suite if the emulator was not found. test.beforeEach(async () => { if (skipReason) test.skip(true, skipReason); }); test.afterAll(async () => { if (enrolledUser && proxyCtx) { const page = await proxyCtx.newPage().catch(() => null); if (page) { await chromeFetch(page, `${PORTAL}/enroll/delete`, { method: 'POST', data: { username: enrolledUser }, }).catch(() => {}); await page.close().catch(() => {}); } } await proxyCtx?.close().catch(() => {}); await device?.close().catch(() => {}); }); // --------------------------------------------------------------------------- // Test 1: no users — non-gated request passes through. // // Chrome navigates to a non-gated host. Component 1 forwards the traffic // directly without contacting Component 2 or the card. // --------------------------------------------------------------------------- test('1. no users: non-gated request passes through', async () => { const page = await proxyCtx.newPage(); try { const response = await page.goto(UNGATED_URL, { timeout: 15_000, waitUntil: 'commit', }); expect(response?.status()).toBeLessThan(500); } finally { await page.close(); } }); // --------------------------------------------------------------------------- // Test 2: no users — gated request blocked. // // Component 1 asks Component 2 for a token; Component 2 finds no enrolled // user and returns an error; Component 1 responds 407. Chrome's fetch() // surfaces this as either a 407 response or a network error. // --------------------------------------------------------------------------- test('2. no users: gated request blocked', async () => { const page = await proxyCtx.newPage(); try { const result = await chromeFetch(page, GATED_URL, { method: GATED_METHOD }); // 407 (proxy auth required) or status 0 (network error) both mean blocked. expect(result.ok).toBe(false); } finally { await page.close(); } }); // --------------------------------------------------------------------------- // Test 3: register user — non-gated request still passes through. // // Card step: makeCredential. card_emulator_bridge.py auto-approves instantly // — no physical fingerprint touch needed. // --------------------------------------------------------------------------- test('3. enroll user: non-gated request still passes through', async () => { test.setTimeout(registrationTimeoutMs + 30_000); enrolledUser = uniqueUsername(); const page = await proxyCtx.newPage(); try { // The portal at 127.0.0.1 bypasses the proxy and loads directly from Component 2. await page.goto(`${PORTAL}/`); await page.locator('#username').fill(enrolledUser); await page.locator('#displayName').fill('Android Chrome Test'); await page.locator('#enrollBtn').click(); await expect(page.locator('#log')).toContainText('Enrolled', { timeout: registrationTimeoutMs, }); await expect(page.locator('#storedUser')).toHaveText(enrolledUser); // Non-gated traffic must still be forwarded directly after enrollment. const response = await page.goto(UNGATED_URL, { timeout: 15_000, waitUntil: 'commit', }); expect(response?.status()).toBeLessThan(500); } finally { await page.close(); } }); // --------------------------------------------------------------------------- // Test 4: with enrolled user — gated request succeeds after card assertion. // // Card step: getAssertion. card_emulator_bridge.py auto-approves instantly. // // fetch() inside Chrome flows: // Chrome → Component 1 (127.0.0.1:8888) → POST /auth/get-token → // Component 2 → card emulator bridge (10.0.2.2:8772) → assertion bundle → // Component 1 → gated endpoint with Authorization: Bearer → 200 response. // // Verification: // httpbin.org echoes the Authorization: Bearer header back in the JSON body. // k_server (GATED_URL=http://k-server-ip:8780/resource/counter) validates // the assertion cryptographically and returns {ok, resource, value}. // --------------------------------------------------------------------------- test('4. enrolled user: gated request succeeds — card asserted', async () => { test.setTimeout(cardAssertionTimeoutMs + 30_000); const page = await proxyCtx.newPage(); try { const result = await chromeFetch(page, GATED_URL, { method: GATED_METHOD, timeoutMs: cardAssertionTimeoutMs, }); // 200 proves Component 2 performed the FIDO2 assertion successfully. expect(result.status).toBe(200); if (result.body?.headers?.Authorization !== undefined) { // httpbin.org echoes request headers — the Bearer token must be present. expect(result.body.headers.Authorization).toMatch(/^Bearer /i); } else if (result.body?.resource !== undefined) { // k_server validated the assertion token and incremented the counter. expect(result.body.ok).toBe(true); expect(result.body.resource).toBe('counter'); expect(typeof result.body.value).toBe('number'); } } finally { await page.close(); } }); });