Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ 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 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.

Expand Down Expand Up @@ -44,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
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
115 changes: 115 additions & 0 deletions scripts/browser-lifecycle-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import assert from "node:assert/strict";
import { chromium, type Page } from "playwright";
import { collectPageSectionIdentities, 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<void> {
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<void> {
await page.goto(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
}

async function runWalker(page: Page, html: string, sections: string[]): Promise<unknown[]> {
await setSnapshot(page, html, sections);
return await page.evaluate(expression) as unknown[];
}

async function main(): Promise<void> {
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 = `<signed-section ${fixtureAttributes("live")}>live</signed-section>`;
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 = `<signed-section ${fixtureAttributes("outer")}>outer <signed-section ${fixtureAttributes("inner")}>inner</signed-section></signed-section>`;
const nestedSections = [
nested,
`<signed-section ${fixtureAttributes("inner")}>inner</signed-section>`,
];
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);
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 = `<signed-section ${fixtureAttributes("mutable")}>before</signed-section>`;
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 = `<signed-section ${fixtureAttributes("first")}>first</signed-section>`;
const second = `<signed-section ${fixtureAttributes("second")}>second</signed-section>`;
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");
8 changes: 4 additions & 4 deletions scripts/run-e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading