k_card/tests/k_phone_android.spec.js

289 lines
12 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, chromium } = require('@playwright/test');
const { execSync } = require('child_process');
const ADB = (() => {
const candidates = [
process.env.ADB,
`${process.env.HOME}/Library/Android/sdk/platform-tools/adb`,
'/usr/local/bin/adb',
].filter(Boolean);
for (const p of candidates) {
try { execSync(`"${p}" version`, { stdio: 'pipe' }); return p; } catch {}
}
return null;
})();
const CDP_PORT = 9222;
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 browser = null; // CDP-connected browser (chromium.connectOverCDP)
let proxyCtx = null; // BrowserContext for test pages
let skipReason = null;
let enrolledUser = null;
test.beforeAll(async () => {
if (!ADB) {
skipReason = 'adb not found — install Android SDK platform-tools';
return;
}
try {
const devicesOut = execSync(`"${ADB}" devices`, { stdio: 'pipe' }).toString();
const connected = devicesOut.split('\n').slice(1).some(l => l.includes('\tdevice'));
if (!connected) {
skipReason = 'No Android emulator connected — run: adb devices';
return;
}
// Write --proxy-server flag into Chrome's command-line file, then restart.
// Chrome on Android reads /data/local/tmp/chrome-command-line on startup.
execSync(`"${ADB}" shell "echo 'chrome --proxy-server=127.0.0.1:8888' > /data/local/tmp/chrome-command-line"`, { stdio: 'pipe' });
execSync(`"${ADB}" shell "chmod 644 /data/local/tmp/chrome-command-line"`, { stdio: 'pipe' });
execSync(`"${ADB}" shell am force-stop com.android.chrome`, { stdio: 'pipe' });
await new Promise(r => setTimeout(r, 1200));
execSync(`"${ADB}" shell am start -n com.android.chrome/com.google.android.apps.chrome.Main about:blank`, { stdio: 'pipe' });
// Poll until Chrome's CDP socket appears (up to 15 s).
execSync(`"${ADB}" forward tcp:${CDP_PORT} localabstract:chrome_devtools_remote`, { stdio: 'pipe' });
browser = null;
for (let attempt = 0; attempt < 15; attempt++) {
await new Promise(r => setTimeout(r, 1000));
try {
browser = await chromium.connectOverCDP(`http://127.0.0.1:${CDP_PORT}`, { timeout: 3000 });
break;
} catch {}
}
if (!browser) throw new Error('Chrome CDP not ready after 15 s');
proxyCtx = browser.contexts()[0] ?? await browser.newContext();
// 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 browser?.close().catch(() => {});
try { execSync(`"${ADB}" forward --remove tcp:${CDP_PORT}`, { stdio: 'pipe' }); } catch {}
try { execSync(`"${ADB}" shell rm /data/local/tmp/chrome-command-line`, { stdio: 'pipe' }); } 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();
}
});
});