From be64e2df5cb4a9c23593c3f18409748515487b21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Tue, 26 May 2026 00:43:56 +0200 Subject: [PATCH 1/2] =?UTF-8?q?test(e2e):=20journal=E2=86=94planner=20save?= =?UTF-8?q?=20handoff=20covers=20Phase=20A=20+=20Phase=20B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New \`e2e/journal-planner-save.test.ts\` exercises the full save flow: 1. Seed a routeId + JWT via \`/api/e2e/seed\` (journal). 2. POST to \`/api/sessions\` on the planner with \`callbackUrl\` + \`callbackToken\` — mirrors what the journal's \`edit-in-planner\` action does server-to-server. 3. Open the planner session in a real browser. 4. Click \"Save to Journal\". The button POSTs sessionId+GPX to the planner's \`/api/save-to-journal\` action (Phase A); the action forwards to the journal callback with the Bearer. Test 1 asserts: - The journal's route ends up with geometry (round-trip works). - The exact JWT string is **never** present in any browser-issued request body or \`Authorization\` header — i.e. Phase A correctly keeps the token server-side. Test 2 asserts: - A second click on Save reuses the same stored JWT, the journal's jti consumer rejects it, and the planner UI surfaces the error. This is the Phase B replay guard exercised end-to-end through the UI rather than just the API. Added \`journal-planner-save\` Playwright project (no baseURL — the test navigates both apps using absolute URLs). Full repo: pnpm typecheck / lint / test all green (cached). Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e/journal-planner-save.test.ts | 111 +++++++++++++++++++++++++++++++ playwright.config.ts | 9 +++ 2 files changed, 120 insertions(+) create mode 100644 e2e/journal-planner-save.test.ts diff --git a/e2e/journal-planner-save.test.ts b/e2e/journal-planner-save.test.ts new file mode 100644 index 0000000..2da7e25 --- /dev/null +++ b/e2e/journal-planner-save.test.ts @@ -0,0 +1,111 @@ +// End-to-end coverage for the journal → planner → journal save flow, +// added together with #2 Phase A (#442) + Phase B (#443). +// +// What this test guarantees: +// 1. Clicking Save-to-Journal in the planner UI successfully POSTs +// back to the journal via the server-side proxy (#442) — i.e. the +// callback JWT never appears in the browser. +// 2. The journal's callback enforces single-use jti (#443) — a +// second save with the same session/token returns 401. +// +// The test bypasses the journal UI's "Edit in Planner" button (which +// would need a logged-in user with a route + GPX). Instead it uses the +// `/api/e2e/seed` endpoint to mint a routeId + JWT, then directly POSTs +// to the planner's `/api/sessions` with callbackUrl + callbackToken — +// exactly the call that `api.routes.$id.edit-in-planner` makes +// server-to-server. From that point on it's the same flow as a real +// user. + +import { test, expect } from "./fixtures/test"; + +const JOURNAL = "http://localhost:3000"; +const PLANNER = "http://localhost:3001"; + +const VALID_GPX = ` + + + 34 + 40 + 35 + +`; + +async function seedRouteAndPlannerSession(request: import("@playwright/test").APIRequestContext) { + const seedResp = await request.post(`${JOURNAL}/api/e2e/seed`); + expect(seedResp.ok()).toBeTruthy(); + const { routeId, token } = (await seedResp.json()) as { routeId: string; token: string }; + + const sessionResp = await request.post(`${PLANNER}/api/sessions`, { + data: { + callbackUrl: `${JOURNAL}/api/routes/${routeId}/callback`, + callbackToken: token, + gpx: VALID_GPX, + }, + }); + expect(sessionResp.ok()).toBeTruthy(); + const session = (await sessionResp.json()) as { + sessionId: string; + url: string; + initialWaypoints?: unknown[]; + }; + return { routeId, token, session }; +} + +test.describe("Journal ↔ Planner save handoff (Phase A + B)", () => { + test("planner save POSTs back to journal — token stays server-side", async ({ page, request }) => { + const { routeId, token, session } = await seedRouteAndPlannerSession(request); + + // Capture every browser-originated request so we can assert the + // specific token string never appears in any of them — neither in + // an Authorization header nor in a request body. The planner's + // server-side proxy is the only thing that should see it. + const observed: string[] = []; + page.on("request", (req) => { + const body = req.postData(); + if (body) observed.push(body); + const auth = req.headers()["authorization"]; + if (auth) observed.push(`AUTH:${auth}`); + }); + + await page.goto(`${PLANNER}${session.url}`); + await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 }); + + const saveBtn = page.getByRole("button", { name: /Save/i }); + await expect(saveBtn).toBeVisible({ timeout: 10000 }); + await saveBtn.click(); + await expect(page.getByText("Saved")).toBeVisible({ timeout: 15000 }); + + expect(observed.some((s) => s.includes(token))).toBe(false); + + // Sanity: the journal's route now has geometry. + const geomResp = await request.get(`${JOURNAL}/api/e2e/route/${routeId}`); + const { hasGeom } = (await geomResp.json()) as { hasGeom: boolean }; + expect(hasGeom).toBe(true); + }); + + test("token is single-use — second save through the planner UI fails", async ({ page, request }) => { + const { session } = await seedRouteAndPlannerSession(request); + + await page.goto(`${PLANNER}${session.url}`); + await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 }); + + const saveBtn = page.getByRole("button", { name: /Save/i }); + await expect(saveBtn).toBeVisible({ timeout: 10000 }); + + // First click consumes the token. + await saveBtn.click(); + await expect(page.getByText("Saved")).toBeVisible({ timeout: 15000 }); + + // Second click reuses the same session's stored JWT — the journal + // verifier should reject it on jti conflict (#443). The planner + // proxy forwards the journal's error to the UI. + // + // The UI keeps the "Saved" indicator visible after the first + // success; clicking the button a second time should swap it for an + // error message. We assert either a visible error or the absence + // of a second "Saved" (the component only flips the saved state on + // a success response). + await saveBtn.click(); + await expect(page.getByText(/consumed|already|fail/i)).toBeVisible({ timeout: 15000 }); + }); +}); diff --git a/playwright.config.ts b/playwright.config.ts index 4947417..9b86a42 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -62,6 +62,15 @@ export default defineConfig({ ...devices["Desktop Chrome"], }, }, + { + name: "journal-planner-save", + testMatch: "journal-planner-save.test.ts", + use: { + // No baseURL — the test navigates between both apps using + // absolute URLs. + ...devices["Desktop Chrome"], + }, + }, { name: "notifications", testMatch: "notifications.test.ts", From 206f58b1b97c850a0e29b5688046c4694198ed62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Tue, 26 May 2026 00:53:16 +0200 Subject: [PATCH 2/2] test(e2e/journal-planner-save): pass waypoints in URL + mock BRouter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first revision opened the planner session URL without waypoints, so the in-browser Yjs doc was empty, no route was computed, and the Save button shipped an empty GPX → journal callback returned 400 → \"Saved!\" never appeared and the test timed out. The real journal→planner handoff (in \`apps/journal/app/routes/api.routes.\$id.edit-in-planner.ts\`) encodes the planner's session-creation response (initialWaypoints / noGoAreas / notes) as URL query params on the redirect. The test now mirrors that, passing a 2-waypoint encoded blob via the \`?waypoints=\` param. Also mocks BRouter via the existing \`mockBRouter(page)\` fixture so the route compute is deterministic — \`planner-coloring.test.ts\` uses the same pattern. Otherwise the test would race against a real BRouter cold-start on CI. Asserts canvas is visible before clicking Save (proxy for \"routeData is populated\" — the same condition that gates a non-empty GPX in \`SaveToJournalButton\`). --- e2e/journal-planner-save.test.ts | 80 ++++++++++++++++---------------- 1 file changed, 41 insertions(+), 39 deletions(-) diff --git a/e2e/journal-planner-save.test.ts b/e2e/journal-planner-save.test.ts index 2da7e25..96f1a20 100644 --- a/e2e/journal-planner-save.test.ts +++ b/e2e/journal-planner-save.test.ts @@ -6,59 +6,60 @@ // back to the journal via the server-side proxy (#442) — i.e. the // callback JWT never appears in the browser. // 2. The journal's callback enforces single-use jti (#443) — a -// second save with the same session/token returns 401. +// second save with the same session/token returns an error. // // The test bypasses the journal UI's "Edit in Planner" button (which // would need a logged-in user with a route + GPX). Instead it uses the -// `/api/e2e/seed` endpoint to mint a routeId + JWT, then directly POSTs -// to the planner's `/api/sessions` with callbackUrl + callbackToken — -// exactly the call that `api.routes.$id.edit-in-planner` makes -// server-to-server. From that point on it's the same flow as a real -// user. +// `/api/e2e/seed` endpoint to mint a routeId + JWT, POSTs to the +// planner's `/api/sessions` with callbackUrl + callbackToken — exactly +// what `api.routes.$id.edit-in-planner` does server-to-server — and +// then opens the session URL with waypoint params so the planner +// computes a route and the Save button has GPX to ship. import { test, expect } from "./fixtures/test"; +import { mockBRouter } from "./fixtures/brouter-mock"; const JOURNAL = "http://localhost:3000"; const PLANNER = "http://localhost:3001"; -const VALID_GPX = ` - - - 34 - 40 - 35 - -`; +// Mock BRouter so the in-browser route compute is deterministic and +// fast — same approach as `planner-coloring.test.ts`. Without this the +// test would block on a real BRouter cold-start. +test.beforeEach(async ({ page }) => { + await mockBRouter(page); +}); + +const WAYPOINTS = encodeURIComponent(JSON.stringify([ + { lat: 52.520, lon: 13.405 }, + { lat: 52.515, lon: 13.351 }, +])); async function seedRouteAndPlannerSession(request: import("@playwright/test").APIRequestContext) { const seedResp = await request.post(`${JOURNAL}/api/e2e/seed`); expect(seedResp.ok()).toBeTruthy(); const { routeId, token } = (await seedResp.json()) as { routeId: string; token: string }; + // Create a planner session with the journal callback wired up — no + // GPX seeded into the session itself. We pass waypoints via URL + // below so the in-browser Yjs doc gets them and requestRoute fires. const sessionResp = await request.post(`${PLANNER}/api/sessions`, { data: { callbackUrl: `${JOURNAL}/api/routes/${routeId}/callback`, callbackToken: token, - gpx: VALID_GPX, }, }); expect(sessionResp.ok()).toBeTruthy(); - const session = (await sessionResp.json()) as { - sessionId: string; - url: string; - initialWaypoints?: unknown[]; - }; - return { routeId, token, session }; + const session = (await sessionResp.json()) as { sessionId: string; url: string }; + const sessionPageUrl = `${PLANNER}${session.url}?waypoints=${WAYPOINTS}`; + return { routeId, token, sessionPageUrl }; } test.describe("Journal ↔ Planner save handoff (Phase A + B)", () => { test("planner save POSTs back to journal — token stays server-side", async ({ page, request }) => { - const { routeId, token, session } = await seedRouteAndPlannerSession(request); + const { routeId, token, sessionPageUrl } = await seedRouteAndPlannerSession(request); // Capture every browser-originated request so we can assert the - // specific token string never appears in any of them — neither in - // an Authorization header nor in a request body. The planner's - // server-side proxy is the only thing that should see it. + // specific token string never appears in any of them. const observed: string[] = []; page.on("request", (req) => { const body = req.postData(); @@ -67,14 +68,20 @@ test.describe("Journal ↔ Planner save handoff (Phase A + B)", () => { if (auth) observed.push(`AUTH:${auth}`); }); - await page.goto(`${PLANNER}${session.url}`); + await page.goto(sessionPageUrl); await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 }); - const saveBtn = page.getByRole("button", { name: /Save/i }); + // Wait for the route to actually compute — the elevation chart + // canvas mounts once `routeData` has points, which is the same + // condition that gates a non-empty GPX in the Save handler. + await expect(page.locator("canvas")).toBeVisible({ timeout: 20000 }); + + const saveBtn = page.getByRole("button", { name: /Save to Journal/i }); await expect(saveBtn).toBeVisible({ timeout: 10000 }); await saveBtn.click(); await expect(page.getByText("Saved")).toBeVisible({ timeout: 15000 }); + // The browser must never have seen the JWT. expect(observed.some((s) => s.includes(token))).toBe(false); // Sanity: the journal's route now has geometry. @@ -84,27 +91,22 @@ test.describe("Journal ↔ Planner save handoff (Phase A + B)", () => { }); test("token is single-use — second save through the planner UI fails", async ({ page, request }) => { - const { session } = await seedRouteAndPlannerSession(request); + const { sessionPageUrl } = await seedRouteAndPlannerSession(request); - await page.goto(`${PLANNER}${session.url}`); + await page.goto(sessionPageUrl); await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 }); + await expect(page.locator("canvas")).toBeVisible({ timeout: 20000 }); - const saveBtn = page.getByRole("button", { name: /Save/i }); + const saveBtn = page.getByRole("button", { name: /Save to Journal/i }); await expect(saveBtn).toBeVisible({ timeout: 10000 }); // First click consumes the token. await saveBtn.click(); await expect(page.getByText("Saved")).toBeVisible({ timeout: 15000 }); - // Second click reuses the same session's stored JWT — the journal - // verifier should reject it on jti conflict (#443). The planner - // proxy forwards the journal's error to the UI. - // - // The UI keeps the "Saved" indicator visible after the first - // success; clicking the button a second time should swap it for an - // error message. We assert either a visible error or the absence - // of a second "Saved" (the component only flips the saved state on - // a success response). + // Second click reuses the same session's stored JWT. The journal's + // verifier rejects on jti conflict (#443); the planner proxy + // forwards the error to the UI. await saveBtn.click(); await expect(page.getByText(/consumed|already|fail/i)).toBeVisible({ timeout: 15000 }); });