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 # 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`. 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. 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 sign-count monotonicity, wrong RP rejection, empty allow-list rejection
- total test count is now 122, all passing locally without card or VMs - 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): 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. - 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. - Development is happening on Mac (not Qubes) — Android emulator is incompatible with Qubes' Xen hypervisor.

View File

@ -1,6 +1,6 @@
# Workplan # 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. 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/proxy.go`: `handleHTTP` uses `GetTokenForRequest(r.URL.String(), r.Method)`.
- `component3/main.go`: `--user` flag removed (Component 2 picks the enrolled user). - `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. - `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 ### 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');
}
});
});