265 lines
10 KiB
JavaScript
265 lines
10 KiB
JavaScript
/**
|
|
* 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();
|
|
}
|
|
});
|
|
});
|