From a7137110097fedbd96c3977235c0cf0407f68330 Mon Sep 17 00:00:00 2001 From: Jason Grey Date: Fri, 28 Aug 2026 05:21:55 -0500 Subject: [PATCH 1/4] fix(e2e): fail closed on browser lifecycle gaps --- README.md | 3 + src/lib/playwright-session.ts | 196 +++++++++++++++++++++++---- tests/lib/playwright-session.test.ts | 78 +++++++++++ 3 files changed, 249 insertions(+), 28 deletions(-) create mode 100644 tests/lib/playwright-session.test.ts diff --git a/README.md b/README.md index df0b6c8..5cc810f 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,9 @@ This harness publishes v1 signed content through WordPress and Hugo, serves it o ## Choose a path - Run `npm test && npm run build` when you are changing TypeScript helpers. +- Run `npm test -- tests/lib/playwright-session.test.ts && npm run build` for + the browser lifecycle evidence checks (source mapping, nested markers, + mutation invalidation, and reload snapshot recovery). - Run `npm run e2e:small` for the complete three-author simulation. - Use the split commands below when you need to inspect the stack between publication and browser verification. diff --git a/src/lib/playwright-session.ts b/src/lib/playwright-session.ts index ed5d79a..982837d 100644 --- a/src/lib/playwright-session.ts +++ b/src/lib/playwright-session.ts @@ -81,6 +81,89 @@ function mapIndicator(indicator: TrustEvaluation["indicator"]): TrustIndicator { return "verified-unknown"; } +export interface SourceSnapshotCapture { + html: string; + url: string; + sections: string[]; +} + +/** + * Capture a response body for one navigation. A failed read deliberately + * returns an empty snapshot; the page walker treats that as unverifiable. + * Keeping this helper outside the browser makes the reload/reset contract + * directly testable without requiring a local Chromium download. + */ +export async function captureSourceSnapshot( + response: { text(): Promise; url(): string } | null, + requestedUrl: string, +): Promise { + if (!response) return { html: "", url: requestedUrl, sections: [] }; + try { + const html = await response.text(); + return { + html, + url: response.url() || requestedUrl, + sections: extractSignedSections(html), + }; + } catch { + return { html: "", url: requestedUrl, sections: [] }; + } +} + +export interface SourceSnapshotMapping { + complete: boolean; + sourceByLiveIndex: Array; +} + +/** + * Match frozen response slices to live sections by their signature-bearing + * identity. Every section must map exactly once. No caller may substitute the + * live outerHTML when this check fails. + */ +export function mapSourceSnapshot( + sourceHtml: string, + sourceSections: readonly string[], + sourceIdentities: readonly string[], + liveIdentities: readonly string[], +): SourceSnapshotMapping { + const empty = () => ({ complete: false, sourceByLiveIndex: liveIdentities.map(() => null) }); + if (!sourceHtml || sourceSections.length === 0 || sourceSections.length !== sourceIdentities.length) return empty(); + if (sourceSections.some((section) => !section) || sourceIdentities.length !== liveIdentities.length) return empty(); + + const queues = new Map(); + sourceIdentities.forEach((identity, index) => { + const queue = queues.get(identity) ?? []; + queue.push(sourceSections[index]); + queues.set(identity, queue); + }); + const sourceByLiveIndex = liveIdentities.map((identity) => queues.get(identity)?.shift() ?? null); + const complete = sourceByLiveIndex.every((section) => section !== null) && + [...queues.values()].every((queue) => queue.length === 0); + return { complete, sourceByLiveIndex: complete ? sourceByLiveIndex : liveIdentities.map(() => null) }; +} + +export function outermostSectionIndex(parentIndexes: readonly number[], index: number): number { + let anchor = index; + while (parentIndexes[anchor] !== undefined && parentIndexes[anchor] >= 0) anchor = parentIndexes[anchor]; + return anchor; +} + +export function displayVerificationState( + cryptoValid: boolean, + inputState: "source-only" | "stale" | "rendered-match", +): { valid: boolean; className: "verified" | "warning" | "unverified"; text: string } { + if (cryptoValid && inputState === "rendered-match") { + return { valid: true, className: "verified", text: "✓ Signature valid" }; + } + if (cryptoValid && inputState === "source-only") { + return { valid: false, className: "warning", text: "⚠ Source signature valid; rendered content not verified" }; + } + if (cryptoValid && inputState === "stale") { + return { valid: false, className: "warning", text: "⚠ Rendered content INVALID (source differs)" }; + } + return { valid: false, className: "unverified", text: "✗ Signature INVALID" }; +} + /** * Reputation lookup that mirrors the e2e prototype's two-step shape: * 1. GET /api/authors/{authorId}/public-key -> { id: keyId, ... } @@ -142,7 +225,7 @@ function authorIdFromKeyid(keyid: string, authors: AuthorProfile[]): string | nu * Why Node-side: key resolution uses Docker-only directory URLs, and the * library resolver chain is deliberately exercised outside page CORS. */ -const DOM_SCRIPT_BODY = ` +export const DOM_SCRIPT_BODY = ` const results = []; const snapshot = window.__htmltrustSourceSnapshot || { html: "", url: window.location.href, sections: [] }; const sourceDocument = new DOMParser().parseFromString(snapshot.html || "", "text/html"); @@ -153,7 +236,7 @@ const DOM_SCRIPT_BODY = ` sourceNodes.forEach((section, index) => { const key = identity(section); const queue = sourceByIdentity.get(key) || []; - queue.push(snapshot.sections[index] || ""); + queue.push(Array.isArray(snapshot.sections) ? snapshot.sections[index] || "" : ""); sourceByIdentity.set(key, queue); }); const sourceBaseElement = sourceDocument.querySelector("base[href]"); @@ -165,12 +248,27 @@ const DOM_SCRIPT_BODY = ` sourceBaseUrl = snapshot.url; } } - const sections = document.querySelectorAll("signed-section"); + const sections = Array.from(document.querySelectorAll("signed-section")); + const markerBySection = new WeakMap(); + const liveIdentityCounts = new Map(); + sections.forEach((section) => { + const key = identity(section); + liveIdentityCounts.set(key, (liveIdentityCounts.get(key) || 0) + 1); + }); + const sourceMappingComplete = Boolean(snapshot.html) && + sourceNodes.length === sections.length && + Array.isArray(snapshot.sections) && + snapshot.sections.length === sourceNodes.length && + snapshot.sections.every((sourceHtml) => typeof sourceHtml === "string" && sourceHtml.length > 0) && + sourceNodes.every((section) => (sourceByIdentity.get(identity(section)) || []).length > 0) && + [...sourceByIdentity.entries()].every(([key, queue]) => queue.length === (liveIdentityCounts.get(key) || 0)); for (const section of sections) { const origin = new URL(snapshot.url).origin; const renderedHtml = section.outerHTML; - const sourceHtml = (sourceByIdentity.get(identity(section)) || []).shift() || ""; - const html = sourceHtml || renderedHtml; + const sourceHtml = sourceMappingComplete ? (sourceByIdentity.get(identity(section)) || []).shift() || "" : ""; + // An unavailable or incomplete response snapshot is an unverifiable + // section. Never replace the signed source with repaired live DOM bytes. + const html = sourceHtml; // Best-effort author display name from inner , // preserved purely for badge data attributes. @@ -195,9 +293,10 @@ const DOM_SCRIPT_BODY = ` const indicator = score.trust.indicator === "green" ? "trusted" : score.trust.indicator === "red" ? "warning" : "verified-unknown"; - const normalizedIndicator = indicator - .replace("verified-unknown", "unknown") - .replace("warning", "untrusted"); + const renderedMatch = score.verify.valid === true && score.verify.inputState === "rendered-match"; + const normalizedIndicator = renderedMatch + ? indicator.replace("verified-unknown", "unknown").replace("warning", "untrusted") + : "unknown"; const cryptoValid = score.verify.valid === true; const contentHashValid = cryptoValid || (score.verify.reason !== "content-hash-mismatch"); @@ -210,10 +309,16 @@ const DOM_SCRIPT_BODY = ` badges.style.cssText = "display: flex; gap: 8px; padding: 8px; margin: 8px 0; font-family: sans-serif; font-size: 14px; align-items: center; flex-wrap: wrap;"; const sigBadge = document.createElement("span"); - if (cryptoValid) { + if (renderedMatch) { sigBadge.className = "cs-verification-badge cs-verification-badge-verified cs-validity-badge"; sigBadge.textContent = "✓ Signature valid"; sigBadge.style.cssText = "background: #d4edda; color: #155724; padding: 4px 8px; border-radius: 4px;"; + } else if (cryptoValid) { + sigBadge.className = "cs-verification-badge cs-verification-badge-warning cs-validity-badge"; + sigBadge.textContent = score.verify.inputState === "stale" + ? "⚠ Rendered content INVALID (source differs)" + : "⚠ Source signature valid; rendered content not verified"; + sigBadge.style.cssText = "background: #fff3cd; color: #856404; padding: 4px 8px; border-radius: 4px;"; } else { sigBadge.className = "cs-verification-badge cs-verification-badge-unverified cs-validity-badge"; sigBadge.textContent = "✗ Signature INVALID"; @@ -224,9 +329,9 @@ const DOM_SCRIPT_BODY = ` const trustBadge = document.createElement("span"); trustBadge.className = "cs-trust-badge cs-trust-badge-" + normalizedIndicator; trustBadge.textContent = "Trust: " + score.trust.score + "%"; - if (score.trust.score >= 70) { + if (normalizedIndicator === "trusted") { trustBadge.style.cssText = "background: #d4edda; color: #155724; padding: 4px 8px; border-radius: 4px;"; - } else if (score.trust.score < 20) { + } else if (normalizedIndicator === "untrusted") { trustBadge.style.cssText = "background: #f8d7da; color: #721c24; padding: 4px 8px; border-radius: 4px;"; } else { trustBadge.style.cssText = "background: #fff3cd; color: #856404; padding: 4px 8px; border-radius: 4px;"; @@ -252,7 +357,15 @@ const DOM_SCRIPT_BODY = ` downvote.style.cssText = "cursor: pointer; padding: 4px 8px; border: 1px solid #ccc; background: white; border-radius: 4px;"; badges.appendChild(downvote); - section.parentNode.insertBefore(badges, section.nextSibling); + badges.setAttribute("data-verification-state", score.verify.inputState || "source-only"); + const previousBadge = markerBySection.get(section); + if (previousBadge) previousBadge.remove(); + // Always anchor after the outermost signed section, including nested + // sections, so extension-owned nodes stay outside signed bytes. + let anchor = section; + while (anchor.parentElement && anchor.parentElement.matches("signed-section")) anchor = anchor.parentElement; + anchor.parentNode && anchor.parentNode.insertBefore(badges, anchor.nextSibling); + markerBySection.set(section, badges); results.push({ authorId: score.authorId, @@ -264,6 +377,21 @@ const DOM_SCRIPT_BODY = ` verificationInputState: score.verify.inputState, verificationReason: score.verify.reason, }); + const result = results[results.length - 1]; + const observer = new MutationObserver(() => { + const badge = markerBySection.get(section); + if (!badge) return; + badge.setAttribute("data-verification-state", "stale"); + const sig = badge.querySelector(".cs-validity-badge"); + if (sig) { + sig.className = "cs-verification-badge cs-verification-badge-warning cs-validity-badge"; + sig.textContent = "⚠ Rendered content INVALID (source differs)"; + sig.style.cssText = "background: #fff3cd; color: #856404; padding: 4px 8px; border-radius: 4px;"; + } + result.verificationInputState = "stale"; + result.verificationReason = "live content changed"; + }); + observer.observe(section, { attributes: true, characterData: true, childList: true, subtree: true }); } return results; `; @@ -393,28 +521,40 @@ export async function runConsumerSession(opts: SessionOptions): Promise { + const names = ["profile", "signature-scope", "signature", "keyid", "algorithm", "content-hash"]; + const identity = (section: Element) => names.map((name) => name + "=" + (section.getAttribute(name) || "")).join("\u001f"); + const sourceDocument = new DOMParser().parseFromString(html || "", "text/html"); + return { + source: Array.from(sourceDocument.querySelectorAll("signed-section")).map(identity), + live: Array.from(document.querySelectorAll("signed-section")).map(identity), + }; + }, sourceSnapshot.html); + const mapping = mapSourceSnapshot( + sourceSnapshot.html, + sourceSnapshot.sections, + identities.source, + identities.live, + ); + const snapshotForPage = mapping.complete + ? sourceSnapshot + : { ...sourceSnapshot, sections: [] }; // Run the DOM walker. It calls __htmltrustVerifyAndScore per - // signed-section, preferring the original source snapshot and - // comparing it to the rendered DOM when both are available. + // signed-section, using only a complete original source snapshot and + // comparing it to the rendered DOM. Missing source fails closed. await page.evaluate((snapshot) => { (window as unknown as { __htmltrustSourceSnapshot?: { html: string; url: string; sections: string[] }; }).__htmltrustSourceSnapshot = snapshot; - }, { html: sourceHtml, url: sourceUrl, sections: sourceSections }); + }, snapshotForPage); const asyncExpression = `(async () => { ${DOM_SCRIPT_BODY} })()`; const results = (await page.evaluate(asyncExpression)) as Array<{ authorId: string | null; diff --git a/tests/lib/playwright-session.test.ts b/tests/lib/playwright-session.test.ts new file mode 100644 index 0000000..99e5127 --- /dev/null +++ b/tests/lib/playwright-session.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { + DOM_SCRIPT_BODY, + captureSourceSnapshot, + displayVerificationState, + mapSourceSnapshot, + outermostSectionIndex, +} from "../../src/lib/playwright-session.js"; + +describe("browser lifecycle evidence", () => { + it("fails closed when response source capture or identity mapping is incomplete", () => { + expect(mapSourceSnapshot("", [""], ["id"], ["id"])).toEqual({ + complete: false, + sourceByLiveIndex: [null], + }); + expect(mapSourceSnapshot("", [""], ["source"], ["live"])).toEqual({ + complete: false, + sourceByLiveIndex: [null], + }); + expect(mapSourceSnapshot( + "", + ["source-one", "source-two"], + ["duplicate", "duplicate"], + ["duplicate", "duplicate"], + )).toEqual({ complete: true, sourceByLiveIndex: ["source-one", "source-two"] }); + }); + + it("places nested markers after the outermost signed section", () => { + // Parent indexes model the DOM tree: section 2 is nested in section 1, + // which is nested in section 0. + expect(outermostSectionIndex([-1, 0, 1], 2)).toBe(0); + expect(outermostSectionIndex([-1, 0, 1], 1)).toBe(0); + expect(outermostSectionIndex([-1, 0, 1], 0)).toBe(0); + }); + + it("renders stale and source-only states as warnings, never as valid", () => { + expect(displayVerificationState(true, "rendered-match")).toEqual({ + valid: true, + className: "verified", + text: "✓ Signature valid", + }); + expect(displayVerificationState(true, "stale")).toEqual({ + valid: false, + className: "warning", + text: "⚠ Rendered content INVALID (source differs)", + }); + expect(displayVerificationState(true, "source-only").valid).toBe(false); + }); + + it("resets the frozen source snapshot on reload and fails closed on a read error", async () => { + const first = await captureSourceSnapshot( + { text: async () => '', url: () => "https://example.test/one" }, + "https://example.test/requested-one", + ); + const second = await captureSourceSnapshot( + { text: async () => '', url: () => "https://example.test/two" }, + "https://example.test/requested-two", + ); + const failed = await captureSourceSnapshot( + { text: async () => { throw new Error("body unavailable"); }, url: () => "https://example.test/failed" }, + "https://example.test/requested-failed", + ); + + expect(first.html).toContain('profile="one"'); + expect(second.html).toContain('profile="two"'); + expect(second.html).not.toContain('profile="one"'); + expect(failed).toEqual({ html: "", url: "https://example.test/requested-failed", sections: [] }); + }); + + it("keeps the production walker fail-closed and lifecycle-aware", () => { + expect(DOM_SCRIPT_BODY).not.toContain("sourceHtml || renderedHtml"); + expect(DOM_SCRIPT_BODY).toContain("const html = sourceHtml;"); + expect(DOM_SCRIPT_BODY).toContain("sourceMappingComplete"); + expect(DOM_SCRIPT_BODY).toContain("new MutationObserver"); + expect(DOM_SCRIPT_BODY).toContain("Rendered content INVALID (source differs)"); + expect(DOM_SCRIPT_BODY).toContain("while (anchor.parentElement && anchor.parentElement.matches(\"signed-section\"))"); + }); +}); From 319b663acc0d938a8d11279c3b6550044fbb7117 Mon Sep 17 00:00:00 2001 From: Jason Grey Date: Fri, 28 Aug 2026 05:28:21 -0500 Subject: [PATCH 2/4] test(e2e): exercise lifecycle walker in Chromium --- README.md | 3 + package.json | 1 + scripts/browser-lifecycle-test.ts | 109 +++++++++++++++++++++++++++ src/lib/playwright-session.ts | 22 ------ tests/lib/playwright-session.test.ts | 39 +--------- 5 files changed, 114 insertions(+), 60 deletions(-) create mode 100644 scripts/browser-lifecycle-test.ts diff --git a/README.md b/README.md index 5cc810f..86c47d3 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,9 @@ This harness publishes v1 signed content through WordPress and Hugo, serves it o - Run `npm test -- tests/lib/playwright-session.test.ts && npm run build` for the browser lifecycle evidence checks (source mapping, nested markers, mutation invalidation, and reload snapshot recovery). +- Run `npm run test:browser` for the same lifecycle checks in the production + DOM walker. This uses the checked-in Playwright Docker image and does not + start the integration stack; `npm test` remains browser-download-free. - Run `npm run e2e:small` for the complete three-author simulation. - Use the split commands below when you need to inspect the stack between publication and browser verification. diff --git a/package.json b/package.json index 048a8f9..c71b7d3 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "stack:down": "docker compose down -v", "smoke": "tsx src/smoke-test.ts scenario-small.yaml", "browser:small": "docker compose run --rm --entrypoint npx playwright tsx src/run-phases-3-5.ts scenario-small.yaml", + "test:browser": "docker compose run --rm --no-deps --entrypoint npx playwright tsx scripts/browser-lifecycle-test.ts", "e2e:small": "./scripts/run-e2e.sh scenario-small.yaml", "test": "vitest run", "test:watch": "vitest" diff --git a/scripts/browser-lifecycle-test.ts b/scripts/browser-lifecycle-test.ts new file mode 100644 index 0000000..1a7f70f --- /dev/null +++ b/scripts/browser-lifecycle-test.ts @@ -0,0 +1,109 @@ +import assert from "node:assert/strict"; +import { chromium, type Page } from "playwright"; +import { DOM_SCRIPT_BODY } from "../src/lib/playwright-session.js"; + +const expression = `(async () => { ${DOM_SCRIPT_BODY} })()`; +const fixtureAttributes = (signature: string): string => + `profile="htmltrust-signature-v1" signature-scope="url" keyid="https://example.test/keys/alice" algorithm="ed25519" content-hash="sha256:${signature}" signature="${signature}"`; + +type ScoreInput = { html: string; renderedHtml: string }; + +async function setSnapshot(page: Page, html: string, sections: string[], url = "https://example.test/article"): Promise { + await page.evaluate(({ html: snapshotHtml, sections: snapshotSections, url: snapshotUrl }) => { + (window as unknown as { __htmltrustSourceSnapshot: unknown }).__htmltrustSourceSnapshot = { + html: snapshotHtml, + url: snapshotUrl, + sections: snapshotSections, + }; + }, { html, sections, url }); +} + +async function navigate(page: Page, html: string): Promise { + await page.goto(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`); +} + +async function runWalker(page: Page, html: string, sections: string[]): Promise { + await setSnapshot(page, html, sections); + return await page.evaluate(expression) as unknown[]; +} + +async function main(): Promise { + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage(); + const calls: ScoreInput[] = []; + await page.exposeFunction("__htmltrustVerifyAndScore", async (input: ScoreInput) => { + calls.push(input); + const sourceAvailable = input.html.length > 0; + const renderedMatch = sourceAvailable && input.html === input.renderedHtml; + return { + verify: { + valid: sourceAvailable, + inputState: renderedMatch ? "rendered-match" : sourceAvailable ? "stale" : "source-only", + reason: sourceAvailable ? undefined : "source snapshot unavailable", + }, + trust: { score: 50, indicator: "yellow", inputs: [] }, + authorId: "alice", + reports: 0, + }; + }); + + try { + // Missing source must reach the verifier as an empty string. A live DOM + // serialization must never be used as a replacement input. + const missing = `live`; + await navigate(page, missing); + calls.length = 0; + const missingResult = await runWalker(page, "", []); + assert.equal(calls[0]?.html, ""); + assert.equal((missingResult[0] as { signatureValid: boolean }).signatureValid, false); + assert.equal(await page.locator(".cs-validity-badge").textContent(), "✗ Signature INVALID"); + + // Every nested section gets a sibling marker after the outermost section. + // This keeps extension-owned UI outside all signed bytes. + const nested = `outer inner`; + const nestedSections = [ + nested, + `inner`, + ]; + await navigate(page, nested); + calls.length = 0; + const nestedResult = await runWalker(page, nested, nestedSections); + assert.equal(nestedResult.length, 2); + assert.equal(await page.locator(".cs-verification-badges").count(), 2); + assert.equal(await page.locator("signed-section > .cs-verification-badges").count(), 0); + assert.equal(await page.locator("body > .cs-verification-badges").count(), 2); + + // A mutation after a successful verification changes the badge without + // rerunning the source verifier. + const mutable = `before`; + await navigate(page, mutable); + calls.length = 0; + await runWalker(page, mutable, [mutable]); + await page.locator("signed-section").evaluate((section) => { section.textContent = "after"; }); + await page.waitForTimeout(0); + assert.equal(await page.locator(".cs-verification-badges").getAttribute("data-verification-state"), "stale"); + assert.equal(await page.locator(".cs-validity-badge").textContent(), "⚠ Rendered content INVALID (source differs)"); + assert.equal(calls.length, 1); + + // A fresh document and snapshot use only the new source. This catches a + // previous page's frozen bytes accidentally surviving a reload. + const first = `first`; + const second = `second`; + await navigate(page, first); + await runWalker(page, first, [first]); + await navigate(page, second); + calls.length = 0; + const reloadResult = await runWalker(page, second, [second]); + assert.equal(calls.length, 1); + assert.equal(calls[0]?.html, second); + assert.equal((reloadResult[0] as { signatureValid: boolean }).signatureValid, true); + assert.equal(await page.locator(".cs-verification-badges").count(), 1); + assert.equal(await page.locator("signed-section").textContent(), "second"); + } finally { + await page.close(); + await browser.close(); + } +} + +await main(); +console.log("browser lifecycle checks passed"); diff --git a/src/lib/playwright-session.ts b/src/lib/playwright-session.ts index 982837d..7f07a6c 100644 --- a/src/lib/playwright-session.ts +++ b/src/lib/playwright-session.ts @@ -142,28 +142,6 @@ export function mapSourceSnapshot( return { complete, sourceByLiveIndex: complete ? sourceByLiveIndex : liveIdentities.map(() => null) }; } -export function outermostSectionIndex(parentIndexes: readonly number[], index: number): number { - let anchor = index; - while (parentIndexes[anchor] !== undefined && parentIndexes[anchor] >= 0) anchor = parentIndexes[anchor]; - return anchor; -} - -export function displayVerificationState( - cryptoValid: boolean, - inputState: "source-only" | "stale" | "rendered-match", -): { valid: boolean; className: "verified" | "warning" | "unverified"; text: string } { - if (cryptoValid && inputState === "rendered-match") { - return { valid: true, className: "verified", text: "✓ Signature valid" }; - } - if (cryptoValid && inputState === "source-only") { - return { valid: false, className: "warning", text: "⚠ Source signature valid; rendered content not verified" }; - } - if (cryptoValid && inputState === "stale") { - return { valid: false, className: "warning", text: "⚠ Rendered content INVALID (source differs)" }; - } - return { valid: false, className: "unverified", text: "✗ Signature INVALID" }; -} - /** * Reputation lookup that mirrors the e2e prototype's two-step shape: * 1. GET /api/authors/{authorId}/public-key -> { id: keyId, ... } diff --git a/tests/lib/playwright-session.test.ts b/tests/lib/playwright-session.test.ts index 99e5127..92479b3 100644 --- a/tests/lib/playwright-session.test.ts +++ b/tests/lib/playwright-session.test.ts @@ -1,11 +1,5 @@ import { describe, expect, it } from "vitest"; -import { - DOM_SCRIPT_BODY, - captureSourceSnapshot, - displayVerificationState, - mapSourceSnapshot, - outermostSectionIndex, -} from "../../src/lib/playwright-session.js"; +import { captureSourceSnapshot, mapSourceSnapshot } from "../../src/lib/playwright-session.js"; describe("browser lifecycle evidence", () => { it("fails closed when response source capture or identity mapping is incomplete", () => { @@ -25,28 +19,6 @@ describe("browser lifecycle evidence", () => { )).toEqual({ complete: true, sourceByLiveIndex: ["source-one", "source-two"] }); }); - it("places nested markers after the outermost signed section", () => { - // Parent indexes model the DOM tree: section 2 is nested in section 1, - // which is nested in section 0. - expect(outermostSectionIndex([-1, 0, 1], 2)).toBe(0); - expect(outermostSectionIndex([-1, 0, 1], 1)).toBe(0); - expect(outermostSectionIndex([-1, 0, 1], 0)).toBe(0); - }); - - it("renders stale and source-only states as warnings, never as valid", () => { - expect(displayVerificationState(true, "rendered-match")).toEqual({ - valid: true, - className: "verified", - text: "✓ Signature valid", - }); - expect(displayVerificationState(true, "stale")).toEqual({ - valid: false, - className: "warning", - text: "⚠ Rendered content INVALID (source differs)", - }); - expect(displayVerificationState(true, "source-only").valid).toBe(false); - }); - it("resets the frozen source snapshot on reload and fails closed on a read error", async () => { const first = await captureSourceSnapshot( { text: async () => '', url: () => "https://example.test/one" }, @@ -66,13 +38,4 @@ describe("browser lifecycle evidence", () => { expect(second.html).not.toContain('profile="one"'); expect(failed).toEqual({ html: "", url: "https://example.test/requested-failed", sections: [] }); }); - - it("keeps the production walker fail-closed and lifecycle-aware", () => { - expect(DOM_SCRIPT_BODY).not.toContain("sourceHtml || renderedHtml"); - expect(DOM_SCRIPT_BODY).toContain("const html = sourceHtml;"); - expect(DOM_SCRIPT_BODY).toContain("sourceMappingComplete"); - expect(DOM_SCRIPT_BODY).toContain("new MutationObserver"); - expect(DOM_SCRIPT_BODY).toContain("Rendered content INVALID (source differs)"); - expect(DOM_SCRIPT_BODY).toContain("while (anchor.parentElement && anchor.parentElement.matches(\"signed-section\"))"); - }); }); From 4fe7fc23a48a59328058ae8517a25c1bfb7641c3 Mon Sep 17 00:00:00 2001 From: Jason Grey Date: Fri, 28 Aug 2026 05:42:26 -0500 Subject: [PATCH 3/4] chore(e2e): refresh coordinated revision pins --- .github/workflows/ci.yml | 4 ++-- README.md | 8 ++++---- package-lock.json | 2 +- scripts/run-e2e.sh | 8 ++++---- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1dbc0f3..483191b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,14 +22,14 @@ jobs: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: repository: HTMLTrust/htmltrust-canonicalization - ref: b0c8f305425de190a7f209ac117d34f88c2b1946 + ref: 5e51040dcaaf50935e245702bdefbc18a1d542ce path: htmltrust-canonicalization persist-credentials: false - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: repository: HTMLTrust/htmltrust-browser-client - ref: d25c6d3c2d0f4d67483da20853f22e94a11b89cc + ref: 39dc873c368ff53b5d0295fbe4d8f493dea52f90 path: htmltrust-browser-client persist-credentials: false diff --git a/README.md b/README.md index 86c47d3..fa5da36 100644 --- a/README.md +++ b/README.md @@ -50,11 +50,11 @@ git clone https://github.com/HTMLTrust/htmltrust-server-reference.git The frozen v1 integration uses these immutable revisions: ```bash -git -C htmltrust-canonicalization checkout b0c8f305425de190a7f209ac117d34f88c2b1946 -git -C htmltrust-browser-client checkout d25c6d3c2d0f4d67483da20853f22e94a11b89cc -git -C htmltrust-browser-reference checkout 5237f07098da8b6542f0fd8f1c613ae8dbf4e6dd +git -C htmltrust-canonicalization checkout 5e51040dcaaf50935e245702bdefbc18a1d542ce +git -C htmltrust-browser-client checkout 39dc873c368ff53b5d0295fbe4d8f493dea52f90 +git -C htmltrust-browser-reference checkout 407bace3ad792384ba623b5db795f3f32acd16ca git -C htmltrust-cms-reference checkout 69aafdfad2c81766f2717b88525f2569370f96cd -git -C htmltrust-server-reference checkout f84f51482ba2a925d9b5ff148185adf6dedef566 +git -C htmltrust-server-reference checkout 56ab5c06e901f8f48753e3a511dd9dda755b9bac ``` The one-command runner checks these revisions and requires clean sibling working diff --git a/package-lock.json b/package-lock.json index 30c2724..b31092c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,7 @@ "version": "0.1.2", "license": "LicenseRef-PolyForm-Noncommercial-1.0.0", "dependencies": { - "@htmltrust/canonicalization": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/b0c8f305425de190a7f209ac117d34f88c2b1946.tar.gz", + "@htmltrust/canonicalization": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/5e51040dcaaf50935e245702bdefbc18a1d542ce.tar.gz", "parse5": "7.3.0" }, "devDependencies": { diff --git a/scripts/run-e2e.sh b/scripts/run-e2e.sh index e938cbe..0965a6b 100755 --- a/scripts/run-e2e.sh +++ b/scripts/run-e2e.sh @@ -21,11 +21,11 @@ required_siblings=( ) declare -A expected_revisions=( - [htmltrust-canonicalization]=b0c8f305425de190a7f209ac117d34f88c2b1946 - [htmltrust-browser-client]=d25c6d3c2d0f4d67483da20853f22e94a11b89cc - [htmltrust-browser-reference]=5237f07098da8b6542f0fd8f1c613ae8dbf4e6dd + [htmltrust-canonicalization]=5e51040dcaaf50935e245702bdefbc18a1d542ce + [htmltrust-browser-client]=39dc873c368ff53b5d0295fbe4d8f493dea52f90 + [htmltrust-browser-reference]=407bace3ad792384ba623b5db795f3f32acd16ca [htmltrust-cms-reference]=69aafdfad2c81766f2717b88525f2569370f96cd - [htmltrust-server-reference]=f84f51482ba2a925d9b5ff148185adf6dedef566 + [htmltrust-server-reference]=56ab5c06e901f8f48753e3a511dd9dda755b9bac ) for repository in "${required_siblings[@]}"; do From 51cfb764ba268be5266c9aae2b95923cd67fb4ac Mon Sep 17 00:00:00 2001 From: Jason Grey Date: Fri, 28 Aug 2026 05:47:45 -0500 Subject: [PATCH 4/4] fix(e2e): keep serialized page preflight self-contained --- scripts/browser-lifecycle-test.ts | 8 +++++++- src/lib/playwright-session.ts | 29 ++++++++++++++++++++--------- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/scripts/browser-lifecycle-test.ts b/scripts/browser-lifecycle-test.ts index 1a7f70f..012d1ce 100644 --- a/scripts/browser-lifecycle-test.ts +++ b/scripts/browser-lifecycle-test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { chromium, type Page } from "playwright"; -import { DOM_SCRIPT_BODY } from "../src/lib/playwright-session.js"; +import { collectPageSectionIdentities, DOM_SCRIPT_BODY } from "../src/lib/playwright-session.js"; const expression = `(async () => { ${DOM_SCRIPT_BODY} })()`; const fixtureAttributes = (signature: string): string => @@ -67,6 +67,12 @@ async function main(): Promise { ]; await navigate(page, nested); calls.length = 0; + // Exercise the exact string page-function used by runConsumerSession's + // source/live preflight. A tsx-transformed callback would carry an + // undefined __name helper across this serialization boundary. + const identities = await page.evaluate(collectPageSectionIdentities, nested) as { source: string[]; live: string[] }; + assert.equal(identities.source.length, 2); + assert.equal(identities.live.length, 2); const nestedResult = await runWalker(page, nested, nestedSections); assert.equal(nestedResult.length, 2); assert.equal(await page.locator(".cs-verification-badges").count(), 2); diff --git a/src/lib/playwright-session.ts b/src/lib/playwright-session.ts index 7f07a6c..60264fd 100644 --- a/src/lib/playwright-session.ts +++ b/src/lib/playwright-session.ts @@ -193,6 +193,22 @@ function authorIdFromKeyid(keyid: string, authors: AuthorProfile[]): string | nu } } +/** + * Page-context source/live identity preflight. Keep the function body + * self-contained because Playwright serializes it into the page: tsx/esbuild + * helpers such as __name are not defined in the browser evaluation realm. + */ +export function collectPageSectionIdentities(html: string): { source: string[]; live: string[] } { + const names = ["profile", "signature-scope", "signature", "keyid", "algorithm", "content-hash"]; + const sourceDocument = new DOMParser().parseFromString(html || "", "text/html"); + return { + source: Array.from(sourceDocument.querySelectorAll("signed-section")).map((section) => + names.map((name) => name + "=" + (section.getAttribute(name) || "")).join("\u001f")), + live: Array.from(document.querySelectorAll("signed-section")).map((section) => + names.map((name) => name + "=" + (section.getAttribute(name) || "")).join("\u001f")), + }; +} + /** * Inline DOM walker + badge renderer. Runs in the page context so that * `document.querySelectorAll`, `window.location`, and DOM mutation are @@ -506,15 +522,10 @@ export async function runConsumerSession(opts: SessionOptions): Promise { - const names = ["profile", "signature-scope", "signature", "keyid", "algorithm", "content-hash"]; - const identity = (section: Element) => names.map((name) => name + "=" + (section.getAttribute(name) || "")).join("\u001f"); - const sourceDocument = new DOMParser().parseFromString(html || "", "text/html"); - return { - source: Array.from(sourceDocument.querySelectorAll("signed-section")).map(identity), - live: Array.from(document.querySelectorAll("signed-section")).map(identity), - }; - }, sourceSnapshot.html); + const identities = await page.evaluate(collectPageSectionIdentities, sourceSnapshot.html) as { + source: string[]; + live: string[]; + }; const mapping = mapSourceSnapshot( sourceSnapshot.html, sourceSnapshot.sections,