/** * Acceptance tests for k_phone Component 1 (filter_proxy) routing behaviour. * * Four tests run serially, building shared state: * 1. No users — non-gated request passes through directly. * 2. No users — gated request is rejected (407 Proxy Authentication Required). * 3. Register user — non-gated request still passes through. * 4. (User enrolled) gated request succeeds after card assertion. * * HTTP proxy requests are made with Node's `http` module so the proxy protocol * (absolute URI in the request line) is exact and Playwright's browser proxy * handling is not involved. The portal page is used for enrollment (test 3) * because that step requires the user to touch the card fingerprint. * * Run: * K_PHONE_PROXY=http://phone-ip:8888 \ * K_PHONE_BASE_URL=http://phone-ip:8771 \ * GATED_URL=http://httpbin.org/get \ * npx playwright test tests/k_phone_proxy.spec.js * * Env vars: * K_PHONE_PROXY Component 1 proxy URL (default: http://127.0.0.1:8888) * K_PHONE_BASE_URL Component 2 portal URL (default: http://127.0.0.1:8771) * 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 makeCredential card step (default: 90000) * CARD_LOGIN_TIMEOUT_MS getAssertion card step (default: 90000) * * Gated host configuration: * gated_hosts.txt on the phone must contain the host from GATED_URL. * The app seeds httpbin.org by default; no manual edit needed for the default case. * For full chain validation against k_server (which verifies the FIDO2 token): * GATED_URL=http://k-server-ip:8780/resource/counter GATED_METHOD=POST */ const { test, expect } = require('@playwright/test'); const http = require('http'); const PROXY_URL = process.env.K_PHONE_PROXY || 'http://127.0.0.1:8888'; const PORTAL_URL = process.env.K_PHONE_BASE_URL || 'http://127.0.0.1:8771'; 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'); function uniqueUsername() { return `pw_${Date.now().toString(36)}`; } // --------------------------------------------------------------------------- // HTTP proxy helper — sends one request through Component 1. // // Sends `method targetUrl HTTP/1.1` (absolute URI — the proxy protocol) to // the proxy host:port and returns { status, body }. The caller sets the // timeout via the options object. // --------------------------------------------------------------------------- function proxyRequest(proxyUrl, method, targetUrl, timeoutMs = 15_000) { return new Promise((resolve, reject) => { const proxy = new URL(proxyUrl); const target = new URL(targetUrl); const req = http.request( { hostname: proxy.hostname, port: Number(proxy.port) || 80, method, path: targetUrl, // absolute URI → proxy protocol headers: { Host: target.host }, }, (res) => { const chunks = []; res.on('data', (c) => chunks.push(c)); res.on('end', () => resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString() })); }, ); req.setTimeout(timeoutMs, () => req.destroy(new Error(`proxy request to ${targetUrl} timed out after ${timeoutMs} ms`))); req.on('error', reject); req.end(); }); } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- test.describe.serial('k_phone proxy routing', () => { let enrolledUser = null; // Ensure no users are enrolled before the suite runs so tests 1 and 2 start // from a clean state — a gated request with no user must be rejected. test.beforeAll(async ({ request }) => { const resp = await request.get(`${PORTAL_URL}/enroll/list`); const { users } = await resp.json(); for (const u of users ?? []) { await request.post(`${PORTAL_URL}/enroll/delete`, { data: { username: u.username }, headers: { 'Content-Type': 'application/json' }, }); } }); // Remove the user enrolled in test 3 after the suite finishes. test.afterAll(async ({ request }) => { if (enrolledUser) { await request.post(`${PORTAL_URL}/enroll/delete`, { data: { username: enrolledUser }, headers: { 'Content-Type': 'application/json' }, }); } }); // --------------------------------------------------------------------------- // Test 1: no users — non-gated request passes through. // // Component 1 forwards non-gated traffic directly to the target host on // port 80 without touching Component 2 or the card. // --------------------------------------------------------------------------- test('1. no users: non-gated request passes through', async () => { const { status } = await proxyRequest(PROXY_URL, 'GET', UNGATED_URL); expect(status).toBeLessThan(500); }); // --------------------------------------------------------------------------- // Test 2: no users — gated request rejected with 407. // // Component 1 calls Component 2 for a Bearer token. Component 2 has no // enrolled user and returns an error. Component 1 replies with // 407 Proxy Authentication Required. // --------------------------------------------------------------------------- test('2. no users: gated request rejected with 407', async () => { const { status } = await proxyRequest(PROXY_URL, GATED_METHOD, GATED_URL); expect(status).toBe(407); }); // --------------------------------------------------------------------------- // Test 3: register user — non-gated request still passes through. // // Card step: makeCredential (touch user fingerprint on ChromeCard). // --------------------------------------------------------------------------- test('3. enroll user: non-gated request still passes through', async ({ page }) => { test.setTimeout(registrationTimeoutMs + 30_000); enrolledUser = uniqueUsername(); // Enroll via portal — requires card fingerprint for makeCredential. await page.goto(`${PORTAL_URL}/`); await page.locator('#username').fill(enrolledUser); await page.locator('#displayName').fill('Playwright Proxy 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 — enrollment must not // break the direct-forward path. const { status } = await proxyRequest(PROXY_URL, 'GET', UNGATED_URL); expect(status).toBeLessThan(500); }); // --------------------------------------------------------------------------- // Test 4: enrolled user — gated request succeeds after card assertion. // // Card step: getAssertion (touch user fingerprint on ChromeCard). // // The 200 response proves: // - Component 1 fetched a token from Component 2. // - Component 2 performed a FIDO2 assertion against the enrolled credential. // - Component 1 forwarded the request to the gated endpoint with the token. // // Response body check (both targets): // httpbin.org — echoes the Authorization: Bearer header in its JSON response. // k_server — validates the assertion cryptographically and returns // { ok: true, resource: "counter", value: N }. // --------------------------------------------------------------------------- test('4. enrolled user: gated request succeeds — card asserted', async () => { test.setTimeout(cardAssertionTimeoutMs + 30_000); const { status, body } = await proxyRequest( PROXY_URL, GATED_METHOD, GATED_URL, cardAssertionTimeoutMs, ); // 200 proves the card assertion was performed and the token was accepted. expect(status).toBe(200); // Verify the token was actually forwarded to the target endpoint. let parsed = null; try { parsed = JSON.parse(body); } catch (_) {} if (parsed?.headers?.Authorization !== undefined) { // httpbin.org echoes request headers — the Bearer token must be present. expect(parsed.headers.Authorization).toMatch(/^Bearer /i); } else if (parsed?.resource !== undefined) { // k_server validated the assertion and returned the counter value. expect(parsed.ok).toBe(true); expect(parsed.resource).toBe('counter'); expect(typeof parsed.value).toBe('number'); } }); });