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:
parent
6f08c7eed4
commit
c6294a46c7
25
Setup.md
25
Setup.md
|
|
@ -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.
|
||||||
|
|
|
||||||
21
Workplan.md
21
Workplan.md
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue