diff --git a/README.md b/README.md index 4ab14e9..2252eae 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Readers: contributors and implementers. The normal workflow is: 2. Run tests and type checking. 3. Build the extension for the browser you use. -After each page load or same-document navigation, the content script refetches the current HTTPS URL using the browser HTTP cache when available. It retains each exact signed-section source slice and a parser-owned DOM element. It verifies the source against the final response URL, then compares it with the live element. If page code changes a signed element, the extension marks it as stale and re-verifies it. A refetch can differ from the original response on personalized, time-varying, or service-worker-controlled pages. Status markers are siblings of ``, so extension UI cannot become signed content. +After each page load or same-document navigation, the content script refetches the current HTTPS URL using the browser HTTP cache when available. It retains each exact signed-section source slice and a parser-owned DOM element. It verifies the source against the final response URL, then compares it with the live element. If page code changes a signed element, the extension marks it as stale and re-verifies it. A refetch can differ from the original response on personalized, time-varying, or service-worker-controlled pages. Status markers are inserted outside the outermost signed section, so extension UI cannot become signed content. ## Quick start @@ -29,7 +29,7 @@ After each page load or same-document navigation, the content script refetches t - npm - Chromium, Firefox, or Safari for loading a built extension -The published dependencies pin browser client commit `d25c6d3c` and canonicalization commit `b0c8f305`. A sibling browser-client checkout is optional. Use one when developing both repositories together. +The published dependencies pin browser client commit `39dc873c` and canonicalization commit `5e51040d`. A sibling browser-client checkout is optional. Use one when developing both repositories together. For a standalone checkout: @@ -74,9 +74,15 @@ container with: ``` The script copies this checkout into the container, installs the pinned browser -client from Git, runs 64 extension tests, checks types and lint, then builds all +client from Git, runs the extension test suite, checks types and lint, then builds all three browser packages. Generated files stay outside the checkout. +The content-script lifecycle suite imports the production `index.ts` functions +with bootstrap disabled only inside Jest. It covers source mapping failures, +nested marker placement, stale/source-only warnings, mutation invalidation, and +navigation snapshot reset; packaged builds retain the normal document-idle +bootstrap. + ### Build Build one browser with `npm run build:chromium`, `npm run build:firefox`, or `npm run build:safari`. Build all targets and zip archives with: @@ -102,7 +108,7 @@ Use the matching `dev:firefox` or `dev:safari` command for another target. Reloa - `captureNavigationSnapshot` retains exact source slices, parser-owned elements, the final response URL, and the document base URL. - `mapSnapshotToLiveSections` pairs source sections with live elements by signed attributes, so page reordering does not pair one signature with another. - `observeSignedSection` watches only the live signed element. Mutations trigger re-verification against the immutable source section. History changes and replacement of signed sections trigger a fresh page refetch. -- The content script inserts markers beside the signed element. The marker, tooltip, and vote controls are outside signed content. +- The content script inserts markers beside the outermost signed element. The marker, tooltip, and vote controls are outside signed content, including when sections are nested. The popup receives copied result records. It cannot mutate the content script's verification cache. diff --git a/jest.config.js b/jest.config.js index 49daebb..e4c8f7a 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,6 +1,9 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'jsdom', + testEnvironmentOptions: { + url: 'https://test.example/article', + }, roots: ['/src'], transform: { '^.+\\.tsx?$': 'ts-jest', @@ -48,4 +51,4 @@ module.exports = { statements: 0, }, }, -}; \ No newline at end of file +}; diff --git a/package-lock.json b/package-lock.json index 5535b16..c59e6f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,8 @@ "version": "0.1.0", "license": "LicenseRef-PolyForm-Noncommercial-1.0.0", "dependencies": { - "@htmltrust/browser-client": "git+https://github.com/HTMLTrust/htmltrust-browser-client.git#d25c6d3c2d0f4d67483da20853f22e94a11b89cc", - "@htmltrust/canonicalization": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/b0c8f305425de190a7f209ac117d34f88c2b1946.tar.gz", + "@htmltrust/browser-client": "git+https://github.com/HTMLTrust/htmltrust-browser-client.git#39dc873c368ff53b5d0295fbe4d8f493dea52f90", + "@htmltrust/canonicalization": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/5e51040dcaaf50935e245702bdefbc18a1d542ce.tar.gz", "@simplewebauthn/typescript-types": "^8.3.4", "axios": "^1.9.0", "js-sha256": "^0.11.0", @@ -790,11 +790,11 @@ }, "node_modules/@htmltrust/browser-client": { "version": "0.1.2", - "resolved": "git+ssh://git@github.com/HTMLTrust/htmltrust-browser-client.git#d25c6d3c2d0f4d67483da20853f22e94a11b89cc", - "integrity": "sha512-yOH+YegJ8kmQAQufHi/SN3STAJcmcfIkcKaGhxPkGd4j9SNdCp7s/mvWysYBcDm/8NydiWhu+dAUQxVAgcn7og==", + "resolved": "git+ssh://git@github.com/HTMLTrust/htmltrust-browser-client.git#39dc873c368ff53b5d0295fbe4d8f493dea52f90", + "integrity": "sha512-uVpf48nk0vnXTUe3sosj8OZJilvdW8hwLt+vZesJlZm/JujcTJD6Upm4h/Kr0LnCx78lUx1IRESYS1XxWsipZw==", "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" }, "peerDependencies": { @@ -803,8 +803,8 @@ }, "node_modules/@htmltrust/canonicalization": { "version": "0.3.0", - "resolved": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/b0c8f305425de190a7f209ac117d34f88c2b1946.tar.gz", - "integrity": "sha512-oeZyQepl+Xub2j0Q+i84jxL21L6z7l/6CHtavTAarqHivCAbA0OZGnIwdt265GMMqtN7co9l1dK6yGtNtBLZ8g==", + "resolved": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/5e51040dcaaf50935e245702bdefbc18a1d542ce.tar.gz", + "integrity": "sha512-omTofsbv/S5XJBXzhOwirxpLD2uSkpVeAyINgQ+eQTi7qmjvWUoA6zTeHFbyQ5d7iXScmQO8/f66hDvfaeYdbA==", "license": "LicenseRef-PolyForm-Noncommercial-1.0.0", "dependencies": { "parse5": "7.3.0" diff --git a/package.json b/package.json index 4b10d07..5188834 100644 --- a/package.json +++ b/package.json @@ -51,8 +51,8 @@ "webpack-cli": "^6.0.1" }, "dependencies": { - "@htmltrust/browser-client": "git+https://github.com/HTMLTrust/htmltrust-browser-client.git#d25c6d3c2d0f4d67483da20853f22e94a11b89cc", - "@htmltrust/canonicalization": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/b0c8f305425de190a7f209ac117d34f88c2b1946.tar.gz", + "@htmltrust/browser-client": "git+https://github.com/HTMLTrust/htmltrust-browser-client.git#39dc873c368ff53b5d0295fbe4d8f493dea52f90", + "@htmltrust/canonicalization": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/5e51040dcaaf50935e245702bdefbc18a1d542ce.tar.gz", "@simplewebauthn/typescript-types": "^8.3.4", "axios": "^1.9.0", "js-sha256": "^0.11.0", diff --git a/src/content-scripts/auto-verify.test.ts b/src/content-scripts/auto-verify.test.ts index c95ecd3..2fb5ff5 100644 --- a/src/content-scripts/auto-verify.test.ts +++ b/src/content-scripts/auto-verify.test.ts @@ -1,33 +1,17 @@ /** - * Tests for the auto-verify flow that runs on DOMContentLoaded. + * Production content-script lifecycle tests. * - * The content script in src/content-scripts/index.ts is hard to import - * directly under jest because it self-bootstraps on module load (it pulls in - * the chromium platform adapter, registers DOM listeners, etc.). What we - * verify here instead are the load-bearing invariants the migration is - * supposed to guarantee: - * - * 1. The selector `signed-section` finds every element the content - * script's autoVerifyPage walks over, including malformed sections. - * 2. Mocking @htmltrust/browser-client and replaying the same end-to-end - * shape autoVerifyPage uses produces a badge container with the - * expected CSS classes for both verified and unverified results, and - * that errors during verification produce an error badge with the - * unverified-class set. This guards the visible UX contract. - * 3. CSS class names used for trust badges line up with the constants - * shipped in the extension stylesheet. - * - * The reusable shape replicated below mirrors the production - * autoVerifyPage()/buildAutoBadges()/buildErrorBadges() in - * content-scripts/index.ts. If you change those, mirror the change here. - * - * NOTE: this file uses element.innerHTML to construct jsdom test fixtures. - * That is safe in a unit test (no untrusted input ever reaches it) and is - * the standard idiom; the security hook may flag it but the warning does - * not apply to test fixtures. + * The module is imported with its normal bootstrap gated only for this Jest + * process. Every assertion below calls the functions used by the packaged + * content script; there is no copied DOM walker or badge renderer here. */ import { CSS_CLASSES } from '../core/common/constants'; -import { SIGNED_SECTION_SELECTOR } from '../core/content/navigation-lifecycle'; +import type { Settings } from '../core/common/types'; +import type { VerifyResult, TrustEvaluation } from '@htmltrust/browser-client'; + +// Must be set before requiring index.ts, whose packaged entrypoint bootstraps +// itself as soon as it is loaded. +(globalThis as { __HTMLTRUST_TESTING__?: boolean }).__HTMLTRUST_TESTING__ = true; jest.mock('@htmltrust/browser-client', () => ({ verifySignedSection: jest.fn(), @@ -35,275 +19,215 @@ jest.mock('@htmltrust/browser-client', () => ({ defaultResolverChain: jest.fn(() => []), })); +// The legacy content-extraction path is outside these lifecycle tests. Mocking +// only that leaf avoids pulling the browser-client's ESM canonicalizer into +// Jest while leaving navigation-lifecycle.ts and index.ts production code +// intact. +jest.mock('../core/content/content-processor', () => ({ + ContentProcessor: jest.fn().mockImplementation(() => ({ extractContent: jest.fn() })), +})); + import { - verifySignedSection, evaluateTrustPolicy, + verifySignedSection, } from '@htmltrust/browser-client'; -const AUTO_BADGE_MARKER = 'cs-auto-verification-badges'; - -/** - * Build a fixture DOM. Wraps element construction so we don't write a - * literal innerHTML string at the call site (keeps the security hook quiet - * and makes the fixture intent explicit). - */ -function fixture(html: string): void { - const container = document.createElement('div'); - // eslint-disable-next-line no-restricted-syntax - container.insertAdjacentHTML('afterbegin', html); - while (document.body.firstChild) document.body.removeChild(document.body.firstChild); - while (container.firstChild) document.body.appendChild(container.firstChild); -} +const { + applySectionStatusUI, + armSectionMutationInvalidation, + autoVerifyPage, + buildAutoBadges, + resetNavigationState, +} = require('./index') as typeof import('./index'); -/** - * Mirror of buildAutoBadges() — kept in lockstep so this test exercises the - * same class-wiring logic the content script applies in the page. - */ -function buildAutoBadges( - verify: any, - trust: any, - inputState: 'source-only' | 'stale' | 'rendered-match' = 'rendered-match', -): HTMLElement { - const badges = document.createElement('div'); - badges.className = `${CSS_CLASSES.VERIFICATION_BADGES} ${AUTO_BADGE_MARKER}`; - - const sigBadge = document.createElement('span'); - const renderedValid = verify.valid && inputState === 'rendered-match'; - if (renderedValid) { - sigBadge.className = `${CSS_CLASSES.VERIFICATION_BADGE} ${CSS_CLASSES.VERIFICATION_BADGE_VERIFIED} ${CSS_CLASSES.VALIDITY_BADGE}`; - } else if (verify.valid) { - sigBadge.className = `${CSS_CLASSES.VERIFICATION_BADGE} ${CSS_CLASSES.VERIFICATION_BADGE_WARNING} ${CSS_CLASSES.VALIDITY_BADGE}`; - } else { - sigBadge.className = `${CSS_CLASSES.VERIFICATION_BADGE} ${CSS_CLASSES.VERIFICATION_BADGE_UNVERIFIED} ${CSS_CLASSES.VALIDITY_BADGE}`; - } - badges.appendChild(sigBadge); - - const trustBadge = document.createElement('span'); - const trustClass = - trust.indicator === 'green' - ? CSS_CLASSES.TRUST_BADGE_TRUSTED - : trust.indicator === 'red' - ? CSS_CLASSES.TRUST_BADGE_UNTRUSTED - : CSS_CLASSES.TRUST_BADGE_UNKNOWN; - trustBadge.className = `${CSS_CLASSES.TRUST_BADGE} ${trustClass}`; - badges.appendChild(trustBadge); +const AUTO_BADGE_MARKER = 'cs-auto-verification-badges'; - return badges; +const settings: Settings = { + autoVerify: true, + showBadges: true, + highlightVerified: true, + highlightUnverified: true, + trustDirectoryUrls: [], + personalTrustList: [], + trustedDomains: [], + authMethod: 'apikey', + serverConfigs: [], + developerDebugLogging: false, +}; + +function verifyShape(overrides: Partial = {}): VerifyResult { + return { + valid: true, + keyid: 'did:web:example.test', + algorithm: 'ed25519', + contentHash: 'sha256:content', + claimsHash: 'sha256:claims', + claims: {}, + signedAt: '2026-08-28T00:00:00Z', + domain: 'https://example.test', + origin: 'https://example.test', + inputState: 'rendered-match', + ...overrides, + }; } -function buildErrorBadges(): HTMLElement { - const badges = document.createElement('div'); - badges.className = `${CSS_CLASSES.VERIFICATION_BADGES} ${AUTO_BADGE_MARKER}`; - const sigBadge = document.createElement('span'); - sigBadge.className = `${CSS_CLASSES.VERIFICATION_BADGE} ${CSS_CLASSES.VERIFICATION_BADGE_UNVERIFIED} ${CSS_CLASSES.VALIDITY_BADGE}`; - badges.appendChild(sigBadge); - return badges; +function trustShape(overrides: Partial = {}): TrustEvaluation { + return { score: 80, indicator: 'green', inputs: [], ...overrides }; } -/** - * Mirror of autoVerifyPage() — minus the settings load and resolver-chain - * construction (those are exercised in content-signing-client.test.ts). This - * isolates the DOM-walking + lib-invocation + badge-insertion logic. - */ -async function autoVerifyPage(): Promise { - const sections = document.querySelectorAll(SIGNED_SECTION_SELECTOR); - for (const section of Array.from(sections)) { - if (section.nextElementSibling?.classList.contains(AUTO_BADGE_MARKER)) { - continue; - } - try { - const verify = await (verifySignedSection as jest.Mock)(section, { - keyResolvers: [], - domain: 'https://test.example', - }); - const trust = await (evaluateTrustPolicy as jest.Mock)(verify, { - personalTrustList: [], - trustedDomains: [], - directorySubscriptions: [], - }); - const badges = buildAutoBadges(verify, trust); - section.parentNode?.insertBefore(badges, section.nextSibling); - } catch { - const badges = buildErrorBadges(); - section.parentNode?.insertBefore(badges, section.nextSibling); - } - } -} +describe('production content-script UI and lifecycle', () => { + let consoleError: jest.SpyInstance; -describe('content script auto-verify (selector and badge wiring)', () => { beforeEach(() => { - while (document.body.firstChild) document.body.removeChild(document.body.firstChild); + document.body.replaceChildren(); jest.clearAllMocks(); - }); - - it('querySelectorAll(signed-section) includes malformed sections for failure reporting', () => { - fixture(` - a - b -
c
- d - `); - const found = document.querySelectorAll(SIGNED_SECTION_SELECTOR); - expect(found.length).toBe(3); - expect(found[0].id).toBe('s1'); - expect(found[1].id).toBe('s2'); - expect(found[2].id).toBe('s4'); - }); - - it('calls verifySignedSection for each signed-section on the page', async () => { - fixture(` - a - b - `); - (verifySignedSection as jest.Mock).mockResolvedValue({ - valid: true, - keyid: 'did:web:example.test', + resetNavigationState(); + (global.fetch as jest.Mock).mockReset(); + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + url: window.location.href, + text: async () => '', }); - (evaluateTrustPolicy as jest.Mock).mockResolvedValue({ - score: 80, - indicator: 'green', - inputs: [], - }); - - await autoVerifyPage(); - - expect(verifySignedSection).toHaveBeenCalledTimes(2); - expect(evaluateTrustPolicy).toHaveBeenCalledTimes(2); + consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined); }); - it('passes a serialized origin and leaves verifier debug disabled by default', async () => { - fixture(`x`); - (verifySignedSection as jest.Mock).mockResolvedValue({ - valid: true, - keyid: 'did:web:example.test', - }); - (evaluateTrustPolicy as jest.Mock).mockResolvedValue({ - score: 80, - indicator: 'green', - inputs: [], - }); - - await autoVerifyPage(); - - const [, options] = (verifySignedSection as jest.Mock).mock.calls[0]; - expect(options.domain).toBe('https://test.example'); - expect(options.debug).toBeUndefined(); + afterEach(() => { + consoleError.mockRestore(); }); - it('applies the verified badge classes when the signature is valid', async () => { - fixture(`x`); - (verifySignedSection as jest.Mock).mockResolvedValue({ - valid: true, - keyid: 'did:web:example.test', - }); - (evaluateTrustPolicy as jest.Mock).mockResolvedValue({ - score: 80, - indicator: 'green', - inputs: [], - }); - - await autoVerifyPage(); - - const badges = document.querySelector(`.${AUTO_BADGE_MARKER}`); - expect(badges).not.toBeNull(); - expect( - badges!.querySelector(`.${CSS_CLASSES.VERIFICATION_BADGE_VERIFIED}`), - ).not.toBeNull(); - expect( - badges!.querySelector(`.${CSS_CLASSES.TRUST_BADGE_TRUSTED}`), - ).not.toBeNull(); + it('anchors nested indicators outside the outermost signed section', () => { + document.body.innerHTML = + 'text'; + const outer = document.querySelector('signed-section')!; + const inner = outer.querySelector('signed-section')!; + + applySectionStatusUI(inner, { + verify: verifyShape(), + inputState: 'rendered-match', + sourceVerified: true, + renderedVerified: true, + displayValid: true, + reason: null, + }, trustShape(), null, settings); + + const badge = document.querySelector(`.${AUTO_BADGE_MARKER}`)!; + expect(badge.parentElement).toBe(document.body); + expect(badge.previousElementSibling).toBe(outer); + expect(outer.querySelector(`.${AUTO_BADGE_MARKER}`)).toBeNull(); }); - it('applies the unverified badge classes when the signature is invalid', async () => { - fixture(`x`); - (verifySignedSection as jest.Mock).mockResolvedValue({ - valid: false, - reason: 'bad-signature', - }); - (evaluateTrustPolicy as jest.Mock).mockResolvedValue({ - score: 0, - indicator: 'red', - inputs: [], - }); - - await autoVerifyPage(); - - const badges = document.querySelector(`.${AUTO_BADGE_MARKER}`); - expect( - badges!.querySelector(`.${CSS_CLASSES.VERIFICATION_BADGE_UNVERIFIED}`), - ).not.toBeNull(); - expect( - badges!.querySelector(`.${CSS_CLASSES.TRUST_BADGE_UNTRUSTED}`), - ).not.toBeNull(); + it.each(['stale', 'source-only'] as const)( + 'shows a warning for cryptographically valid %s results', + (inputState) => { + document.body.innerHTML = 'text'; + const section = document.querySelector('signed-section')!; + applySectionStatusUI(section, { + verify: verifyShape({ inputState }), + inputState, + sourceVerified: true, + renderedVerified: false, + displayValid: false, + reason: inputState === 'stale' ? 'rendered DOM diverged from verified source' : 'rendered DOM not compared', + }, trustShape(), null, settings); + + const badge = document.querySelector(`.${AUTO_BADGE_MARKER}`)!; + expect(badge.querySelector(`.${CSS_CLASSES.VERIFICATION_BADGE_WARNING}`)).not.toBeNull(); + expect(badge.querySelector(`.${CSS_CLASSES.VERIFICATION_BADGE_VERIFIED}`)).toBeNull(); + expect(badge.getAttribute('aria-label')).toContain( + inputState === 'stale' ? 'rendered DOM differs' : 'rendered DOM not compared', + ); + }, + ); + + it('fails closed through autoVerifyPage when the source snapshot is unavailable', async () => { + document.body.innerHTML = 'live'; + + await autoVerifyPage([], settings); + + const badge = document.querySelector(`.${AUTO_BADGE_MARKER}`)!; + expect(badge.querySelector(`.${CSS_CLASSES.VERIFICATION_BADGE_UNVERIFIED}`)).not.toBeNull(); + expect(verifySignedSection).not.toHaveBeenCalled(); + expect(badge.getAttribute('aria-label')).toContain('Signature invalid'); }); - it('applies the unknown trust class when the indicator is yellow', async () => { - fixture(`x`); - (verifySignedSection as jest.Mock).mockResolvedValue({ - valid: true, - keyid: 'did:web:unknown.test', - }); - (evaluateTrustPolicy as jest.Mock).mockResolvedValue({ - score: 40, - indicator: 'yellow', - inputs: [], + it('fails closed through autoVerifyPage when source and live identities do not map', async () => { + const sourceHTML = 'source'; + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + url: window.location.href, + text: async () => sourceHTML, }); + document.body.innerHTML = + 'live'; - await autoVerifyPage(); + await autoVerifyPage([], settings); - const badges = document.querySelector(`.${AUTO_BADGE_MARKER}`); - expect( - badges!.querySelector(`.${CSS_CLASSES.TRUST_BADGE_UNKNOWN}`), - ).not.toBeNull(); + const badge = document.querySelector(`.${AUTO_BADGE_MARKER}`)!; + expect(badge.querySelector(`.${CSS_CLASSES.VERIFICATION_BADGE_UNVERIFIED}`)).not.toBeNull(); + expect(verifySignedSection).not.toHaveBeenCalled(); }); - it('uses a warning badge for source-valid but stale rendered content', () => { - const badges = buildAutoBadges( - { valid: true, keyid: 'did:web:example.test' }, - { score: 80, indicator: 'green', inputs: [] }, - 'stale', + it('invalidates a rendered marker after signed content mutation', async () => { + document.body.innerHTML = 'text'; + const section = document.querySelector('signed-section')!; + applySectionStatusUI(section, { + verify: verifyShape(), + inputState: 'rendered-match', + sourceVerified: true, + renderedVerified: true, + displayValid: true, + reason: null, + }, trustShape(), null, settings); + (verifySignedSection as jest.Mock).mockResolvedValue(verifyShape({ inputState: 'stale' })); + (evaluateTrustPolicy as jest.Mock).mockResolvedValue(trustShape()); + + armSectionMutationInvalidation( + section, + 'text', + 'https://example.test/article', + 'https://example.test/article', + [], + settings, ); - - expect( - badges.querySelector(`.${CSS_CLASSES.VERIFICATION_BADGE_WARNING}`), - ).not.toBeNull(); - expect( - badges.querySelector(`.${CSS_CLASSES.VERIFICATION_BADGE_VERIFIED}`), - ).toBeNull(); + section.textContent = 'changed'; + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const badge = document.querySelector(`.${AUTO_BADGE_MARKER}`)!; + expect(badge.querySelector(`.${CSS_CLASSES.VERIFICATION_BADGE_WARNING}`)).not.toBeNull(); + expect(badge.querySelector(`.${CSS_CLASSES.VERIFICATION_BADGE_VERIFIED}`)).toBeNull(); + expect(verifySignedSection).toHaveBeenCalled(); }); - it('inserts an error badge if verification throws', async () => { - fixture(`x`); - (verifySignedSection as jest.Mock).mockRejectedValue( - new Error('resolver failed'), - ); - - await autoVerifyPage(); - - const badges = document.querySelector(`.${AUTO_BADGE_MARKER}`); - expect(badges).not.toBeNull(); - expect( - badges!.querySelector(`.${CSS_CLASSES.VERIFICATION_BADGE_UNVERIFIED}`), - ).not.toBeNull(); + it('resets navigation state and removes old markers before a reload verification', async () => { + const fetchMock = global.fetch as jest.Mock; + const oldHTML = 'old'; + const newHTML = 'new'; + fetchMock.mockResolvedValue({ ok: true, url: window.location.href, text: async () => oldHTML }); + (verifySignedSection as jest.Mock).mockResolvedValue(verifyShape()); + (evaluateTrustPolicy as jest.Mock).mockResolvedValue(trustShape()); + document.body.innerHTML = oldHTML; + await autoVerifyPage([], settings); + expect(document.querySelector(`.${CSS_CLASSES.VERIFICATION_BADGE_VERIFIED}`)).not.toBeNull(); + + resetNavigationState(); + expect(document.querySelector(`.${AUTO_BADGE_MARKER}`)).toBeNull(); + + // A new DOM section and response represent the post-reload document. The + // verifier must receive the new source slice, never the prior snapshot. + fetchMock.mockResolvedValue({ ok: true, url: window.location.href, text: async () => newHTML }); + document.body.innerHTML = newHTML; + await autoVerifyPage([], settings); + expect(document.querySelector(`.${CSS_CLASSES.VERIFICATION_BADGE_VERIFIED}`)).not.toBeNull(); + const calls = (verifySignedSection as jest.Mock).mock.calls; + expect(calls[calls.length - 1]?.[0]).toBe(newHTML); }); - it('does not double-insert when re-run on the same page', async () => { - fixture(`x`); - (verifySignedSection as jest.Mock).mockResolvedValue({ - valid: true, - keyid: 'did:web:example.test', - }); - (evaluateTrustPolicy as jest.Mock).mockResolvedValue({ - score: 80, - indicator: 'green', - inputs: [], - }); - - await autoVerifyPage(); - await autoVerifyPage(); - - expect(document.querySelectorAll(`.${AUTO_BADGE_MARKER}`).length).toBe(1); - // Verification should only happen once thanks to the marker check. - expect(verifySignedSection).toHaveBeenCalledTimes(1); + it('keeps the production auto badge builder warning-aware', () => { + const warning = buildAutoBadges(verifyShape({ inputState: 'stale' }), trustShape()); + expect(warning.querySelector(`.${CSS_CLASSES.VERIFICATION_BADGE_WARNING}`)).not.toBeNull(); + expect(warning.querySelector(`.${CSS_CLASSES.VERIFICATION_BADGE_VERIFIED}`)).toBeNull(); }); }); diff --git a/src/content-scripts/index.ts b/src/content-scripts/index.ts index 1192c9b..b0a2263 100644 --- a/src/content-scripts/index.ts +++ b/src/content-scripts/index.ts @@ -248,7 +248,7 @@ function redecoratePage(): void { } /** Reset cached state before a same-document navigation or page rerender. */ -function resetNavigationState(): void { +export function resetNavigationState(): void { navigationRun += 1; if (rerenderTimer !== null) { clearTimeout(rerenderTimer); @@ -528,14 +528,14 @@ async function verifySectionWithState( * Walk every on the page and verify it locally. * * Each section is verified independently. A failure on one does not skip - * the others. Markers are inserted as the next sibling of the section - * element, keeping extension-owned nodes out of signed content. + * the others. Markers are inserted after the outermost signed section, + * keeping extension-owned nodes out of signed content even when sections nest. * * Idempotent: if a section already has an auto-marker sibling, it's skipped. * This protects against re-runs (e.g. the script being injected twice on a * page that does its own DOM manipulation). */ -async function autoVerifyPage( +export async function autoVerifyPage( resolverChain: KeyResolver[], settings: Settings, expectedNavigationRun = navigationRun, @@ -710,7 +710,7 @@ async function autoVerifyPage( * The user-facing detailed pills (Signature valid / Trust %) live in the * popup, not on the page. */ -function applySectionStatusUI( +export function applySectionStatusUI( section: Element, run: SectionVerificationRun | null, trust: TrustEvaluation | null, @@ -788,7 +788,7 @@ function clearSectionStatusUI(section: Element): void { } /** Re-verify a section after live content changes, against its frozen source. */ -function armSectionMutationInvalidation( +export function armSectionMutationInvalidation( section: Element, sourceHTML: string | null, sourceDocumentUrl: string | null, @@ -898,7 +898,7 @@ function armSectionMutationInvalidation( * same. CSS classes also match the existing content.css file so the * stylesheet shipped with the extension styles them correctly. */ -function buildAutoBadges(verify: VerifyResult, trust: TrustEvaluation): HTMLElement { +export function buildAutoBadges(verify: VerifyResult, trust: TrustEvaluation): HTMLElement { const authorId = verify.keyid ? authorIdFromKeyid(verify.keyid) : null; const badges = document.createElement('div'); @@ -911,11 +911,19 @@ function buildAutoBadges(verify: VerifyResult, trust: TrustEvaluation): HTMLElem // Signature validity badge const sigBadge = document.createElement('span'); - if (verify.valid) { + const renderedValid = verify.valid && verify.inputState === 'rendered-match'; + if (renderedValid) { sigBadge.className = `${CSS_CLASSES.VERIFICATION_BADGE} ${CSS_CLASSES.VERIFICATION_BADGE_VERIFIED} ${CSS_CLASSES.VALIDITY_BADGE}`; sigBadge.textContent = 'Rendered content verified'; sigBadge.style.cssText = 'background: #d4edda; color: #155724; padding: 4px 8px; border-radius: 4px;'; + } else if (verify.valid) { + sigBadge.className = `${CSS_CLASSES.VERIFICATION_BADGE} ${CSS_CLASSES.VERIFICATION_BADGE_WARNING} ${CSS_CLASSES.VALIDITY_BADGE}`; + sigBadge.textContent = 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 = `${CSS_CLASSES.VERIFICATION_BADGE} ${CSS_CLASSES.VERIFICATION_BADGE_UNVERIFIED} ${CSS_CLASSES.VALIDITY_BADGE}`; sigBadge.textContent = `✗ Signature INVALID${verify.reason ? ` (${verify.reason})` : ''}`; @@ -979,20 +987,6 @@ function buildVoteButton( return btn; } -function buildErrorBadges(reason: string): HTMLElement { - const badges = document.createElement('div'); - badges.className = `${CSS_CLASSES.VERIFICATION_BADGES} ${AUTO_BADGE_MARKER}`; - badges.style.cssText = - 'display: flex; gap: 8px; padding: 8px; margin: 8px 0; font-family: sans-serif; font-size: 14px; align-items: center;'; - const sigBadge = document.createElement('span'); - sigBadge.className = `${CSS_CLASSES.VERIFICATION_BADGE} ${CSS_CLASSES.VERIFICATION_BADGE_UNVERIFIED} ${CSS_CLASSES.VALIDITY_BADGE}`; - sigBadge.textContent = `✗ Verification error: ${reason}`; - sigBadge.style.cssText = - 'background: #f8d7da; color: #721c24; padding: 4px 8px; border-radius: 4px;'; - badges.appendChild(sigBadge); - return badges; -} - /** * Notify background that content was detected. This drives the popup's * "current page" status display and is independent of the auto-verify @@ -1351,10 +1345,19 @@ function listenForMessages() { * DOMContentLoaded check handles the rare case where the script is * injected before the DOM is ready. */ -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => { +export function installContentScript(): void { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + initialize(); + }); + } else { initialize(); - }); -} else { - initialize(); + } +} + +// Jest imports the production functions directly and sets this flag before +// requiring the module. Packaged builds leave it unset, preserving the +// existing document-idle bootstrap behavior exactly. +if (!(globalThis as { __HTMLTRUST_TESTING__?: boolean }).__HTMLTRUST_TESTING__) { + installContentScript(); }