diff --git a/package.json b/package.json index b4fe2dd..64694ec 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "typecheck": "tsc --noEmit", "smoke": "node --test scripts/smoke/smoke.mjs", "smoke:stream": "node --test scripts/smoke/stream.test.mjs", - "smoke:all": "node --test scripts/smoke/smoke.mjs scripts/smoke/stream.test.mjs" + "smoke:pair": "node --test scripts/smoke/pair.test.mjs", + "smoke:all": "node --test scripts/smoke/smoke.mjs scripts/smoke/stream.test.mjs scripts/smoke/pair.test.mjs" }, "devDependencies": { "@biomejs/biome": "^2.4.12", diff --git a/scripts/smoke/pair.test.mjs b/scripts/smoke/pair.test.mjs new file mode 100644 index 0000000..3050df0 --- /dev/null +++ b/scripts/smoke/pair.test.mjs @@ -0,0 +1,197 @@ +/** + * T-1.3 Integration smoke: POST /pair pairing flow. + * + * Requires a running pi process with remote-control (spawned by the before() hook). + * Re-uses the helpers from helpers.mjs. + * + * Tests: + * 1. GET /pair-qr → 200 text/plain containing a pi-remote:// URL + * 2. POST /pair → 200 { bearerToken, sidecarId } with valid pairingToken + * 3. GET /sessions?token= → 200 (bearer token authenticates) + * 4. POST /pair (invalid) → 403 invalid_pairing_token + */ + +import assert from "node:assert/strict"; +import path from "node:path"; +import { after, before, describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; +import { + baseUrl, + createSmokeHome, + fetchText, + killPi, + removeSmokeHome, + spawnPi, + waitForPort, +} from "./helpers.mjs"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const EXTENSION_PATH = path.resolve( + __dirname, + "../../extensions/remote-control", +); + +const SMOKE_PORT = Number(process.env.SMOKE_PORT_PAIR ?? 19878); +const SMOKE_TOKEN = "pair-test-token-deterministic-789"; +const BASE = baseUrl(SMOKE_PORT); +const AUTH = `?token=${SMOKE_TOKEN}`; + +// --------------------------------------------------------------------------- +// Helper: fetch JSON from a URL +// --------------------------------------------------------------------------- +async function fetchJson(url, init) { + const res = await fetch(url, init); + let body; + try { + body = await res.json(); + } catch { + body = null; + } + return { res, body }; +} + +// --------------------------------------------------------------------------- +// Helper: extract a query-param value from a URL string +// --------------------------------------------------------------------------- +function extractParam(urlStr, param) { + // Find the pi-remote:// URL in a potentially multi-line body + const match = urlStr.match(/pi-remote:\/\/[^\s]+/); + if (!match) return null; + // URLSearchParams doesn't parse custom schemes, so strip the scheme+host + const raw = match[0]; + const qIdx = raw.indexOf("?"); + if (qIdx === -1) return null; + const params = new URLSearchParams(raw.slice(qIdx + 1)); + return params.get(param); +} + +// --------------------------------------------------------------------------- +// Lifecycle +// --------------------------------------------------------------------------- + +let piProc = null; +let tmpHome = null; + +describe("T-1.3 pair smoke", () => { + before(async () => { + tmpHome = await createSmokeHome({ port: SMOKE_PORT, token: SMOKE_TOKEN }); + const { proc } = spawnPi({ + extensionPath: EXTENSION_PATH, + fakeHome: tmpHome, + }); + piProc = proc; + await waitForPort({ port: SMOKE_PORT }); + }); + + after(async () => { + if (piProc) await killPi(piProc); + if (tmpHome) await removeSmokeHome(tmpHome); + }); + + // ── Test 1: GET /pair-qr ───────────────────────────────────────────────── + + it("GET /pair-qr returns a pairing URL", async () => { + const { res, body } = await fetchText(`${BASE}/pair-qr${AUTH}`); + assert.equal(res.status, 200, `Expected 200, got ${res.status}: ${body}`); + assert.ok( + body.includes("pi-remote://"), + `Response should contain pi-remote:// URL. Got:\n${body}`, + ); + const token = extractParam(body, "pair"); + assert.ok( + token && token.length > 0, + `Should be able to extract pair token from URL. Body:\n${body}`, + ); + }); + + // ── Test 2: POST /pair with valid pairingToken ──────────────────────────── + + it("POST /pair with valid pairingToken returns bearerToken", async () => { + // Step 1: obtain a fresh pairing token via GET /pair-qr + const { res: qrRes, body: qrBody } = await fetchText( + `${BASE}/pair-qr${AUTH}`, + ); + assert.equal( + qrRes.status, + 200, + `GET /pair-qr failed: ${qrRes.status} ${qrBody}`, + ); + const pairingToken = extractParam(qrBody, "pair"); + assert.ok(pairingToken, "Could not extract pairingToken from /pair-qr"); + + // Step 2: exchange pairingToken for bearerToken + const { res, body } = await fetchJson(`${BASE}/pair`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + pairingToken, + deviceToken: "fake-device-token-smoke", + environment: "sandbox", + }), + }); + assert.equal(res.status, 200, `Expected 200, got ${res.status}: ${JSON.stringify(body)}`); + assert.ok(body, "Response body should be JSON"); + assert.ok( + typeof body.bearerToken === "string" && body.bearerToken.length > 0, + `Response should have bearerToken string. Got: ${JSON.stringify(body)}`, + ); + assert.ok( + typeof body.sidecarId === "string" && body.sidecarId.length > 0, + `Response should have sidecarId string. Got: ${JSON.stringify(body)}`, + ); + }); + + // ── Test 3: bearerToken authenticates GET /sessions ────────────────────── + + it("bearerToken from pair can authenticate GET /sessions", async () => { + // Obtain pairing token + const { res: qrRes, body: qrBody } = await fetchText( + `${BASE}/pair-qr${AUTH}`, + ); + assert.equal(qrRes.status, 200, `GET /pair-qr failed: ${qrRes.status}`); + const pairingToken = extractParam(qrBody, "pair"); + assert.ok(pairingToken, "Could not extract pairingToken"); + + // Exchange for bearer token + const { res: pairRes, body: pairBody } = await fetchJson(`${BASE}/pair`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ pairingToken }), + }); + assert.equal( + pairRes.status, + 200, + `POST /pair failed: ${pairRes.status} ${JSON.stringify(pairBody)}`, + ); + const { bearerToken } = pairBody; + assert.ok(bearerToken, "Must have bearerToken to test auth"); + + // Use bearerToken to call GET /sessions + const { res: sessRes, body: sessBody } = await fetchJson( + `${BASE}/sessions?token=${encodeURIComponent(bearerToken)}`, + ); + assert.equal( + sessRes.status, + 200, + `GET /sessions with bearerToken should return 200, got ${sessRes.status}: ${JSON.stringify(sessBody)}`, + ); + assert.ok(Array.isArray(sessBody), "GET /sessions should return an array"); + }); + + // ── Test 4: POST /pair with invalid pairingToken → 403 ─────────────────── + + it("POST /pair with invalid pairingToken returns 403", async () => { + const { res, body } = await fetchJson(`${BASE}/pair`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ pairingToken: "invalid-xxx" }), + }); + assert.equal( + res.status, + 403, + `Expected 403 for invalid token, got ${res.status}: ${JSON.stringify(body)}`, + ); + assert.ok(body, "Should return JSON error body"); + assert.equal(body.error, "invalid_pairing_token"); + }); +});