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