k_card/tests/k_phone_proxy.spec.js

205 lines
9.0 KiB
JavaScript

/**
* 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');
}
});
});