Add Playwright acceptance tests for k_phone proxy routing

Three new specs in tests/:
- k_phone_portal.spec.js: portal UI flow (enroll/login/status/logout/delete)
- k_phone_proxy.spec.js: 4 serial proxy-routing tests via Node http module;
  requires adb forward for emulator use
- k_phone_android.spec.js: same 4 tests with Chrome running inside the
  Android emulator via playwright.android; no port-forward needed,
  auto-skips if no ADB device found

All tests use card_emulator_bridge.py for instant FIDO2 auto-approval —
no physical card or fingerprint interaction required in emulator mode.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Morten V. Christiansen 2026-05-08 12:43:40 +02:00
parent 6f08c7eed4
commit c6294a46c7
5 changed files with 634 additions and 3 deletions

View File

@ -1,6 +1,6 @@
# Setup
Last updated: 2026-04-29
Last updated: 2026-05-08
This is a living setup/status file for the local ChromeCard workspace at `/home/user/chromecard`.
Update this file whenever environment status or verified behavior changes.
@ -668,6 +668,29 @@ Session note (2026-04-27, card emulator and bug fixes):
sign-count monotonicity, wrong RP rejection, empty allow-list rejection
- total test count is now 122, all passing locally without card or VMs
Session note (2026-05-08, per-request token binding + Playwright acceptance tests):
- Per-request FIDO2 token binding implemented across the full stack:
- `k_phone/lib/fido2_ops.dart`: `GetAssertionResult` carries `clientDataJson`; `getAssertion()` accepts optional bound challenge.
- `k_phone/lib/proxy_service.dart`: `_handleAuthGetToken` rewritten — accepts `{url, method, nonce}`, derives `challenge = SHA256(url|method|nonce)`, returns a self-contained assertion bundle as base64url Bearer token. No session created.
- `k_phone/lib/filter_proxy.dart`: `_getAuthToken(uri, method)` generates a 16-byte secure nonce, POSTs `{url, method, nonce}` to Component 2.
- `component3/phone.go`: rewritten as stateless `GetTokenForRequest(url, method)` — no session cache, no mutex.
- `k_server_app.py`: `_verify_assertion_token()` added — verifies path+method, challenge, and ECDSA-P256 signature from the self-contained bundle. `_is_proxy_authorized()` accepts legacy `X-Proxy-Token` or `Authorization: Bearer <bundle>`.
- Test coverage added:
- `tests/test_k_server.py`: 17 Python tests for `_verify_assertion_token` — 12 unit + 5 CardEmulator round-trips. All pass.
Run: `uv run --python 3.12 --with fido2 --with cbor2 --with cryptography python3 -m unittest tests/test_k_server.py`
- `k_phone/test/filter_proxy_test.dart`: 2 new tests verify `/auth/get-token` body fields. 48/48 pass.
- Playwright acceptance tests added (three specs, all in `tests/`):
- `k_phone_portal.spec.js`: portal UI flow — enroll → login → status → list → logout → delete. DOM assertions only; no phone screen needed.
Run: `K_PHONE_BASE_URL=http://phone-ip:8771 npx playwright test tests/k_phone_portal.spec.js`
- `k_phone_proxy.spec.js`: 4 serial proxy-routing tests using Node `http` module.
1. No users → non-gated passes. 2. No users → gated rejected (407). 3. Enroll (card) → non-gated still passes. 4. Gated succeeds with card assertion (200 + Bearer token in response).
Run: `K_PHONE_PROXY=http://127.0.0.1:8888 K_PHONE_BASE_URL=http://127.0.0.1:8771 npx playwright test tests/k_phone_proxy.spec.js` (requires `adb forward tcp:8888 tcp:8888 && adb forward tcp:8771 tcp:8771`)
- `k_phone_android.spec.js`: same 4 tests but Chrome runs inside the Android emulator via Playwright Android (`playwright.android.devices()`). No adb port-forward needed — `127.0.0.1:8888` is Component 1 from inside the emulator. Auto-skips if no ADB device found.
Prerequisite: `npm install playwright` + card_emulator_bridge.py running.
Run: `npx playwright test tests/k_phone_android.spec.js [--headed]`
- card_emulator_bridge.py auto-approves all FIDO2 operations instantly — no physical fingerprint or card needed for emulator tests. The `CARD_REGISTRATION_TIMEOUT_MS` / `CARD_LOGIN_TIMEOUT_MS` timeouts exist only for physical ChromeCard use.
- Flutter analyze: no issues. `go build ./...`: clean. 48/48 Flutter tests pass.
Session note (2026-04-29, Phase 9 k_phone bring-up):
- Phase 9 approved and started: Flutter Android app (`k_phone`) replaces `k_proxy` in the auth chain.
- Development is happening on Mac (not Qubes) — Android emulator is incompatible with Qubes' Xen hypervisor.

View File

@ -1,6 +1,6 @@
# Workplan
Last updated: 2026-04-29
Last updated: 2026-05-08
This is the execution plan for making ChromeCard FIDO2 development and validation reproducible on this machine.
@ -665,7 +665,24 @@ Component 3 (`component3/`) and Component 1 (`k_phone/lib/filter_proxy.dart`) im
- `component3/proxy.go`: `handleHTTP` uses `GetTokenForRequest(r.URL.String(), r.Method)`.
- `component3/main.go`: `--user` flag removed (Component 2 picks the enrolled user).
- `k_server_app.py`: `_verify_assertion_token()` added — decodes bundle, verifies path+method match, verifies challenge claim, verifies ECDSA-P256 signature over authData||clientDataHash using public key extracted from bundle's credentialData. `_is_proxy_authorized()` accepts either X-Proxy-Token (legacy k_proxy path) or Bearer assertion token.
- 46/46 Flutter tests pass; `go build ./...` clean; `flutter analyze` no issues.
- `filter_proxy_test.dart`: 2 new tests for `/auth/get-token` body fields (url, method, nonce). 48/48 tests pass.
- `tests/test_k_server.py`: 17 Python tests for `_verify_assertion_token` — 12 unit tests with synthetic P-256 keys, 5 round-trip tests via `CardEmulator`. All pass.
- 48/48 Flutter tests pass; `go build ./...` clean; `flutter analyze` no issues.
### Work completed (2026-05-08, Playwright acceptance tests for k_phone)
- `tests/k_phone_portal.spec.js` (new): Portal UI acceptance tests (enroll → login → status → list → logout → delete). DOM assertions against `#storedUser`, `#sessionActive`, `#log`. Also tests empty-username and unknown-user error paths.
- Run: `K_PHONE_BASE_URL=http://phone-ip:8771 npx playwright test tests/k_phone_portal.spec.js`
- `tests/k_phone_proxy.spec.js` (new): Proxy routing acceptance tests. Four serial tests that prove Component 1's routing decisions:
1. No users → non-gated request passes through (< 500).
2. No users → gated request rejected with 407 (Component 2 has no enrolled user).
3. Register user (card fingerprint) → non-gated still passes through.
4. With enrolled user → gated request succeeds after card assertion (200); response body proves Bearer token was forwarded to target.
- Uses Node `http` module for proxy requests (absolute URI / proxy protocol).
- Uses Playwright `page` fixture for enrollment in test 3 (card interaction).
- `GATED_URL` defaults to `http://httpbin.org/get`; point at `http://k-server-ip:8780/resource/counter` (GATED_METHOD=POST) for full chain validation including token signature verification.
- Run: `K_PHONE_PROXY=http://phone-ip:8888 K_PHONE_BASE_URL=http://phone-ip:8771 npx playwright test tests/k_phone_proxy.spec.js`
### Next action

View File

@ -0,0 +1,264 @@
/**
* 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();
}
});
});

View File

@ -0,0 +1,123 @@
/**
* Playwright acceptance test for the k_phone portal (Component 2, port 8771).
*
* Run:
* K_PHONE_BASE_URL=http://192.168.x.x:8771 npx playwright test tests/k_phone_portal.spec.js
*
* Env vars:
* K_PHONE_BASE_URL Base URL of the k_phone proxy service (default: http://127.0.0.1:8771)
* CARD_REGISTRATION_TIMEOUT_MS Timeout for makeCredential card step (default: 90000)
* CARD_LOGIN_TIMEOUT_MS Timeout for getAssertion card step (default: 90000)
* PW_HEADLESS Set to "1" for headless mode
*
* Constraint: the test does not read the Android log all assertions are
* made against visible DOM state and the #log pre element.
*/
const { test, expect } = require("@playwright/test");
const BASE_URL = process.env.K_PHONE_BASE_URL || "http://127.0.0.1:8771";
const registrationTimeoutMs = Number(process.env.CARD_REGISTRATION_TIMEOUT_MS || "90000");
const loginTimeoutMs = Number(process.env.CARD_LOGIN_TIMEOUT_MS || "90000");
function uniqueUsername() {
return `pw_${Date.now().toString(36)}`;
}
async function waitForLog(page, expectedText, timeoutMs = 10_000) {
await expect(page.locator("#log")).toContainText(expectedText, { timeout: timeoutMs });
}
test.describe("k_phone portal regression", () => {
test(
"enrolls, logs in, checks session status, logs out, and deletes user",
async ({ page }) => {
const username = uniqueUsername();
test.setTimeout(registrationTimeoutMs + loginTimeoutMs + 60_000);
await page.goto(BASE_URL + "/");
await expect(
page.getByRole("heading", { name: "ChromeCard k_phone Portal" })
).toBeVisible();
// Clear any leftover localStorage from a previous session so the test
// starts from a clean slate regardless of browser profile state.
await page.evaluate(() => localStorage.clear());
await page.reload();
await test.step("Initial state is unauthenticated", async () => {
await expect(page.locator("#storedUser")).toHaveText("none");
await expect(page.locator("#sessionActive")).toHaveText("no");
});
await test.step("Enroll user", async () => {
await page.locator("#username").fill(username);
await page.locator("#displayName").fill("Playwright Test");
// Card step: makeCredential — touch user fingerprint on ChromeCard.
await page.locator("#enrollBtn").click();
await waitForLog(page, "Enrolled", registrationTimeoutMs);
await expect(page.locator("#storedUser")).toHaveText(username);
});
await test.step("Login", async () => {
// Card step: getAssertion — touch user fingerprint on ChromeCard.
await page.locator("#loginBtn").click();
await waitForLog(page, "Login ok", loginTimeoutMs);
await expect(page.locator("#sessionActive")).toHaveText("yes");
});
await test.step("Session status reflects active session", async () => {
await page.locator("#statusBtn").click();
await waitForLog(page, "Session status");
});
await test.step("List users includes enrolled user", async () => {
await page.locator("#listBtn").click();
await waitForLog(page, username);
});
await test.step("Logout clears session", async () => {
await page.locator("#logoutBtn").click();
// "Logout" is a substring of "Logout failed", so assert the semantic
// outcome (sessionActive → no) rather than the log message text.
await expect(page.locator("#sessionActive")).toHaveText("no", {
timeout: 10_000,
});
});
await test.step("Delete user clears stored identity", async () => {
await page.locator("#deleteBtn").click();
// "Deleted" is not a substring of "Delete failed" — safe to match.
await waitForLog(page, "Deleted");
await expect(page.locator("#storedUser")).toHaveText("none");
await expect(page.locator("#sessionActive")).toHaveText("no");
});
}
);
test("enrollment failure is surfaced in log", async ({ page }) => {
await page.goto(BASE_URL + "/");
await page.evaluate(() => localStorage.clear());
await page.reload();
// Submit enroll with an empty username — server must reject it.
await page.locator("#username").fill("");
await page.locator("#enrollBtn").click();
await waitForLog(page, "Enroll failed");
// No username must have been stored on failure.
await expect(page.locator("#storedUser")).toHaveText("none");
});
test("login without enrollment fails gracefully", async ({ page }) => {
await page.goto(BASE_URL + "/");
await page.evaluate(() => localStorage.clear());
await page.reload();
// Attempt login with a username that is not enrolled.
await page.locator("#username").fill("no_such_user_pw");
await page.locator("#loginBtn").click();
await waitForLog(page, "Login failed");
await expect(page.locator("#sessionActive")).toHaveText("no");
});
});

204
tests/k_phone_proxy.spec.js Normal file
View File

@ -0,0 +1,204 @@
/**
* 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');
}
});
});