k_card/tests/k_phone_android.spec.js

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();
}
});
});