test(e2e): journal↔planner save handoff covers Phase A + Phase B

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) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-05-26 00:43:56 +02:00
parent 11edcbccb3
commit be64e2df5c
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
2 changed files with 120 additions and 0 deletions

View file

@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1">
<trk><trkseg>
<trkpt lat="52.52" lon="13.405"><ele>34</ele></trkpt>
<trkpt lat="52.51" lon="13.38"><ele>40</ele></trkpt>
<trkpt lat="52.50" lon="13.35"><ele>35</ele></trkpt>
</trkseg></trk>
</gpx>`;
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 });
});
});

View file

@ -62,6 +62,15 @@ export default defineConfig({
...devices["Desktop Chrome"], ...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", name: "notifications",
testMatch: "notifications.test.ts", testMatch: "notifications.test.ts",