@certrev/cert-block 0.5.4 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. package/CHANGELOG.md +195 -12
  2. package/LICENSE +21 -0
  3. package/README.md +422 -50
  4. package/dist/builder/cert-chrome-data.d.ts +26 -8
  5. package/dist/builder/cert-chrome-data.d.ts.map +1 -1
  6. package/dist/builder/cert-chrome-data.js +5 -5
  7. package/dist/builder/cert-chrome-data.js.map +1 -1
  8. package/dist/builder/cert-review-card.d.ts +35 -5
  9. package/dist/builder/cert-review-card.d.ts.map +1 -1
  10. package/dist/builder/cert-review-card.js +115 -34
  11. package/dist/builder/cert-review-card.js.map +1 -1
  12. package/dist/builder/index.d.ts +33 -16
  13. package/dist/builder/index.d.ts.map +1 -1
  14. package/dist/builder/index.js +33 -16
  15. package/dist/builder/index.js.map +1 -1
  16. package/dist/components/CertBadge.d.ts +2 -2
  17. package/dist/components/CertBadge.d.ts.map +1 -1
  18. package/dist/components/CertBadge.js +11 -5
  19. package/dist/components/CertBadge.js.map +1 -1
  20. package/dist/components/CertJsonLd.d.ts.map +1 -1
  21. package/dist/components/CertJsonLd.js.map +1 -1
  22. package/dist/components/CertRevBacklink.d.ts.map +1 -1
  23. package/dist/components/CertRevBacklink.js +3 -2
  24. package/dist/components/CertRevBacklink.js.map +1 -1
  25. package/dist/components/CertReview.d.ts +1 -1
  26. package/dist/components/ExpertBio.d.ts.map +1 -1
  27. package/dist/components/ExpertBio.js +12 -4
  28. package/dist/components/ExpertBio.js.map +1 -1
  29. package/dist/components/escape.d.ts +28 -1
  30. package/dist/components/escape.d.ts.map +1 -1
  31. package/dist/components/escape.js +37 -8
  32. package/dist/components/escape.js.map +1 -1
  33. package/dist/components/format.d.ts +51 -2
  34. package/dist/components/format.d.ts.map +1 -1
  35. package/dist/components/format.js +103 -6
  36. package/dist/components/format.js.map +1 -1
  37. package/dist/components/preview-block.d.ts +1 -1
  38. package/dist/components/preview-block.js +3 -3
  39. package/dist/components/preview-block.js.map +1 -1
  40. package/dist/components/rel.d.ts +24 -0
  41. package/dist/components/rel.d.ts.map +1 -0
  42. package/dist/components/rel.js +24 -0
  43. package/dist/components/rel.js.map +1 -0
  44. package/dist/components/render-cert-block.d.ts +50 -22
  45. package/dist/components/render-cert-block.d.ts.map +1 -1
  46. package/dist/components/render-cert-block.js +146 -80
  47. package/dist/components/render-cert-block.js.map +1 -1
  48. package/dist/components/render-def.d.ts +13 -14
  49. package/dist/components/render-def.d.ts.map +1 -1
  50. package/dist/components/render-def.js +14 -15
  51. package/dist/components/render-def.js.map +1 -1
  52. package/dist/index.d.ts +1 -0
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +4 -2
  55. package/dist/index.js.map +1 -1
  56. package/dist/jsonld/project.d.ts +46 -13
  57. package/dist/jsonld/project.d.ts.map +1 -1
  58. package/dist/jsonld/project.js +139 -56
  59. package/dist/jsonld/project.js.map +1 -1
  60. package/dist/modal/avatar-resize.d.ts +10 -10
  61. package/dist/modal/avatar-resize.js +14 -14
  62. package/dist/modal/avatar-resize.js.map +1 -1
  63. package/dist/modal/cert-modal-view.d.ts +27 -7
  64. package/dist/modal/cert-modal-view.d.ts.map +1 -1
  65. package/dist/modal/cert-modal-view.js +49 -25
  66. package/dist/modal/cert-modal-view.js.map +1 -1
  67. package/dist/modal/certrev-cert-modal.d.ts +5 -5
  68. package/dist/modal/certrev-cert-modal.js +9 -9
  69. package/dist/modal/certrev-cert-modal.js.map +1 -1
  70. package/dist/modal/certrev-cert-modal.styles.d.ts +6 -6
  71. package/dist/modal/certrev-cert-modal.styles.d.ts.map +1 -1
  72. package/dist/modal/certrev-cert-modal.styles.js +8 -8
  73. package/dist/modal/certrev-cert-modal.styles.js.map +1 -1
  74. package/dist/modal/certrev-cert.entry.d.ts +8 -6
  75. package/dist/modal/certrev-cert.entry.d.ts.map +1 -1
  76. package/dist/modal/certrev-cert.entry.js +9 -7
  77. package/dist/modal/certrev-cert.entry.js.map +1 -1
  78. package/dist/modal/certrev-cert.js +6 -6
  79. package/dist/modal/display-strings.d.ts +25 -5
  80. package/dist/modal/display-strings.d.ts.map +1 -1
  81. package/dist/modal/display-strings.js +27 -5
  82. package/dist/modal/display-strings.js.map +1 -1
  83. package/dist/modal/escape.d.ts +26 -3
  84. package/dist/modal/escape.d.ts.map +1 -1
  85. package/dist/modal/escape.js +32 -3
  86. package/dist/modal/escape.js.map +1 -1
  87. package/dist/modal/font-face.d.ts.map +1 -1
  88. package/dist/modal/font-face.js +8 -11
  89. package/dist/modal/font-face.js.map +1 -1
  90. package/dist/modal/ftc-disclosure.d.ts +2 -2
  91. package/dist/modal/ftc-disclosure.js +2 -2
  92. package/dist/modal/ftc-guard.d.ts +44 -9
  93. package/dist/modal/ftc-guard.d.ts.map +1 -1
  94. package/dist/modal/ftc-guard.js +50 -17
  95. package/dist/modal/ftc-guard.js.map +1 -1
  96. package/dist/modal/index.d.ts +1 -1
  97. package/dist/modal/index.js +1 -1
  98. package/dist/modal/interactions.d.ts +2 -2
  99. package/dist/modal/interactions.js +2 -2
  100. package/dist/modal/logo-paths.js +2 -2
  101. package/dist/modal/logo-paths.js.map +1 -1
  102. package/dist/modal/placement.d.ts +4 -3
  103. package/dist/modal/placement.d.ts.map +1 -1
  104. package/dist/modal/placement.js +7 -6
  105. package/dist/modal/placement.js.map +1 -1
  106. package/dist/modal/revalidate.d.ts +2 -3
  107. package/dist/modal/revalidate.d.ts.map +1 -1
  108. package/dist/modal/revalidate.js +2 -3
  109. package/dist/modal/revalidate.js.map +1 -1
  110. package/dist/modal/verify-client.d.ts +2 -2
  111. package/dist/modal/verify-client.js +2 -2
  112. package/dist/verify/cache.d.ts +21 -6
  113. package/dist/verify/cache.d.ts.map +1 -1
  114. package/dist/verify/cache.js +29 -8
  115. package/dist/verify/cache.js.map +1 -1
  116. package/dist/verify/fetch-with-deadline.d.ts +35 -0
  117. package/dist/verify/fetch-with-deadline.d.ts.map +1 -0
  118. package/dist/verify/fetch-with-deadline.js +55 -0
  119. package/dist/verify/fetch-with-deadline.js.map +1 -0
  120. package/dist/verify/get-verified-envelope.d.ts +23 -9
  121. package/dist/verify/get-verified-envelope.d.ts.map +1 -1
  122. package/dist/verify/get-verified-envelope.js +89 -40
  123. package/dist/verify/get-verified-envelope.js.map +1 -1
  124. package/dist/verify/resolve-kid.d.ts +3 -0
  125. package/dist/verify/resolve-kid.d.ts.map +1 -1
  126. package/dist/verify/resolve-kid.js +17 -8
  127. package/dist/verify/resolve-kid.js.map +1 -1
  128. package/dist/webcomponent/certrev-badge.d.ts.map +1 -1
  129. package/dist/webcomponent/certrev-badge.js +24 -2
  130. package/dist/webcomponent/certrev-badge.js.map +1 -1
  131. package/dist/webcomponent/render-badge-html.d.ts +1 -1
  132. package/dist/webcomponent/render-badge-html.d.ts.map +1 -1
  133. package/dist/webcomponent/render-badge-html.js +36 -17
  134. package/dist/webcomponent/render-badge-html.js.map +1 -1
  135. package/package.json +14 -2
  136. package/src/builder/cert-chrome-data.ts +26 -8
  137. package/src/builder/cert-review-card.tsx +147 -38
  138. package/src/builder/index.tsx +34 -17
  139. package/src/components/CertBadge.tsx +14 -9
  140. package/src/components/CertJsonLd.tsx +1 -1
  141. package/src/components/CertRevBacklink.tsx +10 -3
  142. package/src/components/CertReview.tsx +1 -1
  143. package/src/components/ExpertBio.tsx +12 -7
  144. package/src/components/escape.ts +37 -7
  145. package/src/components/format.ts +102 -6
  146. package/src/components/preview-block.ts +3 -3
  147. package/src/components/rel.ts +23 -0
  148. package/src/components/render-cert-block.ts +192 -109
  149. package/src/components/render-def.ts +14 -15
  150. package/src/index.ts +4 -2
  151. package/src/jsonld/project.ts +155 -59
  152. package/src/modal/avatar-resize.ts +14 -14
  153. package/src/modal/cert-modal-view.ts +52 -27
  154. package/src/modal/certrev-cert-modal.styles.ts +8 -8
  155. package/src/modal/certrev-cert-modal.ts +9 -9
  156. package/src/modal/certrev-cert.entry.ts +9 -7
  157. package/src/modal/display-strings.ts +26 -5
  158. package/src/modal/escape.ts +32 -4
  159. package/src/modal/font-face.ts +8 -11
  160. package/src/modal/ftc-disclosure.ts +2 -2
  161. package/src/modal/ftc-guard.ts +73 -18
  162. package/src/modal/index.ts +1 -1
  163. package/src/modal/interactions.ts +2 -2
  164. package/src/modal/logo-paths.ts +2 -2
  165. package/src/modal/placement.ts +7 -6
  166. package/src/modal/revalidate.ts +2 -3
  167. package/src/modal/verify-client.ts +2 -2
  168. package/src/verify/cache.ts +34 -8
  169. package/src/verify/fetch-with-deadline.ts +60 -0
  170. package/src/verify/get-verified-envelope.ts +131 -48
  171. package/src/verify/resolve-kid.ts +22 -6
  172. package/src/webcomponent/certrev-badge.ts +24 -2
  173. package/src/webcomponent/render-badge-html.ts +66 -19
  174. package/src/__tests__/__snapshots__/render-cert-block.test.ts.snap +0 -17
  175. package/src/__tests__/components.test.tsx +0 -206
  176. package/src/__tests__/preview-block.test.ts +0 -84
  177. package/src/__tests__/project.test.ts +0 -128
  178. package/src/__tests__/render-cert-block.test.ts +0 -657
  179. package/src/__tests__/render-def.test.tsx +0 -295
  180. package/src/__tests__/verify.test.ts +0 -239
  181. package/src/__tests__/webcomponent.test.tsx +0 -116
  182. package/src/builder/__tests__/builder.test.tsx +0 -158
  183. package/src/modal/cert-modal-view.test.ts +0 -175
  184. package/src/modal/certrev-cert-modal.test.ts +0 -125
  185. package/src/modal/ftc-guard.test.ts +0 -153
  186. package/src/modal/interactions.test.ts +0 -250
  187. package/src/modal/placement.test.ts +0 -398
  188. package/src/modal/revalidate.test.ts +0 -111
  189. package/src/modal/verify-client.test.ts +0 -164
@@ -1,158 +0,0 @@
1
- /**
2
- * SSR-render tests for the Builder.io adapter (`@certrev/cert-block/builder`), the panel-validated
3
- * "ambient-URL anchor" model — exercised through `renderToStaticMarkup` (the edge SSR path).
4
- *
5
- * Contract proved here:
6
- * • the anchor renders the CURRENT page's verified badge — and badge ONLY (JSON-LD is emitted
7
- * server-side, decoupled from the visual slot);
8
- * • EVERY non-render path (suppress verdict, no credential for the page) renders nothing in
9
- * production (fail-closed);
10
- * • in the Builder editor (`isEditing`) with no live credential, a truthful placeholder shows —
11
- * never a fake badge, and never any JSON-LD;
12
- * • the registration is ZERO-input: the editor cannot choose or forge which credential renders.
13
- */
14
- import { renderToStaticMarkup } from 'react-dom/server'
15
- import { describe, expect, it } from 'vitest'
16
- import { makeMockPayload } from '../../contract/fixtures.js'
17
- import type { CertVerdict } from '../../contract/kernel.js'
18
- import { CertRevAnchor, CertRevProvider, certRevAnchorComponent, type CurrentCredential } from '../index.js'
19
-
20
- const renderVerdict: CertVerdict = { decision: 'render', payload: makeMockPayload() }
21
- const suppressVerdict: CertVerdict = { decision: 'suppress', reason: 'revoked' }
22
-
23
- function render(current: CurrentCredential | null, isEditing = false): string {
24
- return renderToStaticMarkup(
25
- <CertRevProvider current={current} isEditing={isEditing}>
26
- <CertRevAnchor />
27
- </CertRevProvider>,
28
- )
29
- }
30
-
31
- describe('Builder adapter — registration (zero-input anchor)', () => {
32
- it('registers "CertREV Review" with NO inputs (editor places it; system resolves the credential)', () => {
33
- expect(certRevAnchorComponent.name).toBe('CertREV Review')
34
- expect(certRevAnchorComponent.component).toBe(CertRevAnchor)
35
- expect(certRevAnchorComponent.inputs).toEqual([])
36
- })
37
- })
38
-
39
- describe('Builder adapter — CertRevAnchor render', () => {
40
- it('renders the current page’s verified badge — and ONLY the badge (no JSON-LD; that is server-side)', () => {
41
- const html = render({ verdict: renderVerdict, pageUrl: 'https://brand.example.com/a' })
42
- expect(html).toContain('certrev-badge')
43
- expect(html).toContain('Dr. Jane Doe')
44
- expect(html).not.toContain('application/ld+json') // JSON-LD decoupled to the article template
45
- })
46
-
47
- it('FAIL-CLOSED: a suppress verdict renders nothing', () => {
48
- expect(render({ verdict: suppressVerdict })).toBe('')
49
- })
50
-
51
- it('FAIL-CLOSED: no credential for this page renders nothing in production', () => {
52
- expect(render(null, false)).toBe('')
53
- })
54
-
55
- it('EDITOR: no live credential + isEditing renders a truthful placeholder (no badge, no JSON-LD)', () => {
56
- const html = render(null, true)
57
- expect(html).toContain('data-certrev-editor-placeholder')
58
- expect(html).toContain('resolves at publish')
59
- expect(html).not.toContain('certrev-badge')
60
- expect(html).not.toContain('application/ld+json')
61
- })
62
-
63
- it('EDITOR: a live verified credential renders the REAL badge even in editing mode (true draft-verify)', () => {
64
- const html = render({ verdict: renderVerdict }, true)
65
- expect(html).toContain('certrev-badge')
66
- expect(html).not.toContain('data-certrev-editor-placeholder')
67
- })
68
- })
69
-
70
- // ── POR-10721 W1/W3: the PUSH kit (CertReviewCard) + contract lock ──
71
- import {
72
- BUILDER_CERT_CHROME_KEYS,
73
- BUILDER_REGISTRATION,
74
- type BuilderCertChromeData,
75
- CERT_COMPONENT_NAME,
76
- certRevCertComponent,
77
- CertReviewCard,
78
- WIRE_INPUTS,
79
- } from '../index.js'
80
-
81
- describe('POR-10721 W3 — Builder kit contract lock (inputs ⇄ wire-type parity)', () => {
82
- it('WIRE_INPUTS names are EXACTLY the wire-type keys, in order (no silent drift)', () => {
83
- expect(WIRE_INPUTS.map((i) => i.name)).toEqual([...BUILDER_CERT_CHROME_KEYS])
84
- })
85
- it('BUILDER_REGISTRATION carries every wire input + the placement inputs, named canonically', () => {
86
- expect(BUILDER_REGISTRATION.name).toBe(CERT_COMPONENT_NAME)
87
- const inputNames = new Set(BUILDER_REGISTRATION.inputs.map((i) => i.name))
88
- for (const k of BUILDER_CERT_CHROME_KEYS) expect(inputNames.has(k)).toBe(true)
89
- expect(inputNames.has('mode')).toBe(true) // a placement input, NOT a wire-type key
90
- })
91
- })
92
-
93
- describe('POR-10721 — certRevCertComponent (the drop-in gen2 registration)', () => {
94
- it('carries the identical W3-locked registration as BUILDER_REGISTRATION (name/component/inputs)', () => {
95
- expect(certRevCertComponent.name).toBe(CERT_COMPONENT_NAME)
96
- expect(certRevCertComponent.component).toBe(CertReviewCard)
97
- expect(certRevCertComponent.inputs).toEqual([...BUILDER_REGISTRATION.inputs])
98
- })
99
- it('inputs is a FRESH mutable array — never the canonical readonly instance', () => {
100
- expect(certRevCertComponent.inputs).not.toBe(BUILDER_REGISTRATION.inputs)
101
- expect(Object.isFrozen(certRevCertComponent.inputs)).toBe(false)
102
- })
103
- it('wire inputs sit under the editor "Advanced" reveal; placement inputs stay front-and-center', () => {
104
- const byName = new Map(certRevCertComponent.inputs.map((i) => [i.name, i]))
105
- for (const k of BUILDER_CERT_CHROME_KEYS) expect(byName.get(k)?.advanced).toBe(true)
106
- expect(byName.get('mode')?.advanced).toBeUndefined()
107
- expect(byName.get('part')?.advanced).toBeUndefined()
108
- })
109
- })
110
-
111
- describe('POR-10721 W1 — CertReviewCard render + the fused-credential R1 gate', () => {
112
- const base: Partial<BuilderCertChromeData> = {
113
- reviewerName: 'Dr. Jane Doe',
114
- verifyUrl: 'https://certrev.com/verify/abc',
115
- bylinePlain: 'Reviewed on 2026-03-08 by Dr. Jane Doe.',
116
- certifiedAt: '2026-03-08',
117
- scopeLine: 'Independent editorial review.',
118
- display: { showExpertPhoto: true, showCredentials: true },
119
- renderDef: null,
120
- stringsVersion: '2026-07',
121
- }
122
- it('renders a certified face (the reviewer name)', () => {
123
- expect(renderToStaticMarkup(<CertReviewCard {...base} />)).toContain('Dr. Jane Doe')
124
- })
125
- it('name-only when credentialVerification is null — a credential never leaks without its date', () => {
126
- expect(renderToStaticMarkup(<CertReviewCard {...base} credentialVerification={null} />)).not.toContain(
127
- 'FAAAAI',
128
- )
129
- })
130
- it('renders the credential ONLY as the fused (credential + verifiedAt) pair', () => {
131
- const html = renderToStaticMarkup(
132
- <CertReviewCard {...base} credentialVerification={{ credential: 'FAAAAI', verifiedAt: '2026-03-01' }} />,
133
- )
134
- expect(html).toContain('FAAAAI')
135
- })
136
- it('showCredentials:false collapses to name-only even with the pair present', () => {
137
- const html = renderToStaticMarkup(
138
- <CertReviewCard
139
- {...base}
140
- display={{ showExpertPhoto: true, showCredentials: false }}
141
- credentialVerification={{ credential: 'FAAAAI', verifiedAt: '2026-03-01' }}
142
- />,
143
- )
144
- expect(html).not.toContain('FAAAAI')
145
- })
146
- it('returns nothing pre-cert (no reviewerName / verifyUrl)', () => {
147
- expect(renderToStaticMarkup(<CertReviewCard />)).toBe('')
148
- })
149
- it('passes `part` through to renderCertBlock (POR-10741 — header excludes the memo, memo excludes the header)', () => {
150
- const withMemo = { ...base, memo: 'Checked the claims against current guidance; accurate.' }
151
- const header = renderToStaticMarkup(<CertReviewCard {...withMemo} part="header" />)
152
- const memo = renderToStaticMarkup(<CertReviewCard {...withMemo} part="memo" />)
153
- expect(header).toContain('Expert reviewed')
154
- expect(header).not.toContain('Checked the claims against current guidance; accurate.')
155
- expect(memo).toContain('Checked the claims against current guidance; accurate.')
156
- expect(memo).not.toContain('Expert reviewed')
157
- })
158
- })
@@ -1,175 +0,0 @@
1
- /** Unit tests for the pure cert-modal view (POR-10102): envelope → dialog HTML. */
2
-
3
- import { describe, expect, it } from "vitest";
4
- import {
5
- type CertModalContent,
6
- deriveInitials,
7
- renderCertDialogInner,
8
- renderExpertDialogInner,
9
- safeHref,
10
- signatureName,
11
- stripProtocol,
12
- } from "./cert-modal-view.js";
13
- import { CERT_SCOPE_LINE, COMPENSATED_EXPERT_CUE } from "./display-strings.js";
14
-
15
- const FULL: CertModalContent = {
16
- expert: {
17
- displayName: "Dr. Erik Schraga, MD",
18
- credentials: [
19
- { cardLabel: "Board-Certified" },
20
- { cardLabel: "Emergency Medicine" },
21
- ],
22
- profileUrl: "https://certrev.com/expert/abc",
23
- photoUrl: null,
24
- bio: "Short bio.",
25
- background: "Reviews health content for accuracy and responsible framing.",
26
- },
27
- certifiedAt: "2026-04-14T00:00:00.000Z",
28
- verifyUrl: "https://certrev.com/verify/abc123",
29
- articleTitle: "Spring Cold or Spring Allergies?",
30
- displayCertId: "CR-2026-0512",
31
- display: { showExpertPhoto: false, showBio: true },
32
- };
33
-
34
- describe("name helpers", () => {
35
- it("deriveInitials strips honorific AND post-nominal (ES, not EM)", () => {
36
- expect(deriveInitials("Dr. Erik Schraga, MD")).toBe("ES");
37
- expect(deriveInitials("Jane Doe")).toBe("JD");
38
- expect(deriveInitials("Prof. Ada Lovelace, PhD")).toBe("AL");
39
- expect(deriveInitials("Madonna")).toBe("MA");
40
- expect(deriveInitials("")).toBe("");
41
- });
42
- it("signatureName is the bare given+family name", () => {
43
- expect(signatureName("Dr. Erik Schraga, MD")).toBe("Erik Schraga");
44
- expect(signatureName("Jane Doe")).toBe("Jane Doe");
45
- });
46
- it("stripProtocol / safeHref", () => {
47
- expect(stripProtocol("https://certrev.com/verify/x/")).toBe(
48
- "certrev.com/verify/x",
49
- );
50
- expect(safeHref("https://certrev.com/x")).toBe("https://certrev.com/x");
51
- expect(safeHref("javascript:alert(1)")).toBe("#");
52
- expect(safeHref("/relative")).toBe("#");
53
- expect(safeHref(null)).toBe("#");
54
- });
55
- });
56
-
57
- describe("renderCertDialogInner (3a)", () => {
58
- it("renders the header, name, creds, article title, cert id, signature, stamp, CTA", () => {
59
- const html = renderCertDialogInner(FULL) ?? "";
60
- expect(html).toContain("Certificate of Expert Review");
61
- expect(html).toContain("Dr. Erik Schraga, MD");
62
- expect(html).toContain("Board-Certified · Emergency Medicine");
63
- expect(html).toContain("Spring Cold or Spring Allergies?");
64
- expect(html).toContain("CR-2026-0512");
65
- expect(html).toContain(">Erik Schraga<"); // Allura signature = core name
66
- expect(html).toContain("certrev.com/verify/abc123"); // footer url, protocol stripped
67
- expect(html).toContain("CERTREV VERIFIED · CERTIFIED"); // the stamp textPath
68
- expect(html).toContain("View full certificate");
69
- expect(html).toContain('href="https://certrev.com/verify/abc123"');
70
- });
71
- it("fails closed with no reviewer name", () => {
72
- expect(
73
- renderCertDialogInner({
74
- ...FULL,
75
- expert: { ...FULL.expert, displayName: "" },
76
- }),
77
- ).toBeNull();
78
- expect(renderCertDialogInner({ ...FULL, expert: null })).toBeNull();
79
- });
80
- it('drops the "Certifies the article" block when no title', () => {
81
- const html = renderCertDialogInner({ ...FULL, articleTitle: null }) ?? "";
82
- expect(html).not.toContain("Certifies the article");
83
- });
84
- it("drops the Certificate ID column when no display id", () => {
85
- const html = renderCertDialogInner({ ...FULL, displayCertId: null }) ?? "";
86
- expect(html).not.toContain("Certificate ID");
87
- expect(html).toContain("Issued"); // other meta cols still present
88
- });
89
- it("escapes a hostile reviewer name", () => {
90
- const html =
91
- renderCertDialogInner({
92
- ...FULL,
93
- expert: { ...FULL.expert, displayName: "<script>x</script>" },
94
- }) ?? "";
95
- expect(html).not.toContain("<script>x</script>");
96
- expect(html).toContain("&lt;script&gt;");
97
- });
98
- });
99
-
100
- describe("renderExpertDialogInner (4b)", () => {
101
- it("renders the header, name, verified credential chips, bio, trust, CTA", () => {
102
- const html = renderExpertDialogInner(FULL) ?? "";
103
- expect(html).toContain("Verified reviewer");
104
- expect(html).toContain("Dr. Erik Schraga, MD");
105
- expect(html).toContain(">Board-Certified<");
106
- expect(html).toContain(">Emergency Medicine<");
107
- expect(html).toContain("Reviews health content for accuracy"); // verified background as bio
108
- expect(html).toContain("Independently reviewed · Certified Apr 14, 2026");
109
- expect(html).toContain("View full profile on CertREV");
110
- expect(html).toContain('href="https://certrev.com/expert/abc"');
111
- });
112
- it("NEVER renders an affiliation subtitle (omitted — no data source)", () => {
113
- const html = renderExpertDialogInner(FULL) ?? "";
114
- expect(html).not.toContain("crm-id__subtitle");
115
- });
116
- it("drops the bio when showBio is false", () => {
117
- const html =
118
- renderExpertDialogInner({ ...FULL, display: { showBio: false } }) ?? "";
119
- expect(html).not.toContain("crm-bio");
120
- });
121
- it("renders no chips when there are no credentials", () => {
122
- const html =
123
- renderExpertDialogInner({
124
- ...FULL,
125
- expert: { ...FULL.expert, credentials: [] },
126
- }) ?? "";
127
- expect(html).not.toContain("crm-chips");
128
- });
129
- it("drops a javascript: profile url to #", () => {
130
- const html =
131
- renderExpertDialogInner({
132
- ...FULL,
133
- expert: { ...FULL.expert, profileUrl: "javascript:alert(1)" },
134
- }) ?? "";
135
- expect(html).toContain('href="#"');
136
- expect(html).not.toContain("javascript:");
137
- });
138
- it("fails closed with no reviewer name", () => {
139
- expect(
140
- renderExpertDialogInner({
141
- ...FULL,
142
- expert: { ...FULL.expert, displayName: null },
143
- }),
144
- ).toBeNull();
145
- });
146
- });
147
-
148
- describe("FTC disclosure footer (POR-10496)", () => {
149
- const COMPENSATED: CertModalContent = {
150
- ...FULL,
151
- expert: { ...FULL.expert, compensationCue: COMPENSATED_EXPERT_CUE },
152
- };
153
- // A pro-bono reviewer carries no cue (FULL omits `compensationCue`).
154
- const PRO_BONO = FULL;
155
-
156
- for (const [label, render] of [
157
- ["reviewer (4b)", renderExpertDialogInner],
158
- ["certificate (3a)", renderCertDialogInner],
159
- ] as const) {
160
- it(`${label}: compensated → the cue label + the scope line`, () => {
161
- const html = render(COMPENSATED) ?? "";
162
- expect(html).toContain("crm-disclosure");
163
- expect(html).toContain("crm-disclosure__cue");
164
- expect(html).toContain(COMPENSATED_EXPERT_CUE);
165
- expect(html).toContain(CERT_SCOPE_LINE);
166
- });
167
- it(`${label}: pro-bono → the scope line ONLY (no cue label)`, () => {
168
- const html = render(PRO_BONO) ?? "";
169
- expect(html).toContain("crm-disclosure"); // footer + scope line always render
170
- expect(html).toContain(CERT_SCOPE_LINE);
171
- expect(html).not.toContain("crm-disclosure__cue"); // the "Compensated expert" label is dropped
172
- expect(html).not.toContain(COMPENSATED_EXPERT_CUE);
173
- });
174
- }
175
- });
@@ -1,125 +0,0 @@
1
- // @vitest-environment jsdom
2
- /**
3
- * Unit tests — the `<certrev-cert-modal>` kill-switch decision (POR-10101 / POR-10684).
4
- *
5
- * `isArtifactSuppressed` is the modal-side lifecycle gate: a fetched Delivery-API artifact that is
6
- * REVOKED (the slim `CertTombstone` OR a legacy envelope with `revokedAt` set) or EXPIRED must NOT
7
- * render "verified" content. The route-integration + control-plane tests prove the tombstone is
8
- * SERVED and cryptographically verifies to `suppress:'revoked'`; this pins the modal's consumption
9
- * of it — including the SLIM tombstone (no payload), which a `payload.lifecycle` read alone would
10
- * miss so `markSuppressed()` would never fire (POR-10684).
11
- */
12
-
13
- import type {
14
- CertDeliveryArtifact,
15
- CertDeliveryEnvelope,
16
- CertTombstone,
17
- } from "@certrev/cert-contract";
18
- import { afterEach, describe, expect, it, vi } from "vitest";
19
- import { CERT_MODAL_TAG, isArtifactSuppressed } from "./certrev-cert-modal.js";
20
-
21
- function envelope(
22
- lifecycle: Partial<CertDeliveryEnvelope["payload"]["lifecycle"]>,
23
- ): CertDeliveryEnvelope {
24
- // Only `lifecycle` matters to the gate; the rest is filler cast through `unknown`.
25
- return {
26
- payload: {
27
- lifecycle: {
28
- issuedAt: "2026-01-01T00:00:00.000Z",
29
- expiresAt: "2099-01-01T00:00:00.000Z",
30
- revokedAt: null,
31
- revision: 1,
32
- ...lifecycle,
33
- },
34
- },
35
- signature: {},
36
- } as unknown as CertDeliveryEnvelope;
37
- }
38
-
39
- /** A SLIM revocation tombstone (POR-10481): `kind: 'tombstone'`, subject + revocation facts, NO payload. */
40
- function tombstone(): CertTombstone {
41
- return {
42
- kind: "tombstone",
43
- contractVersion: 1,
44
- subject: { platform: "shopify", externalId: "gid://shopify/Article/12345" },
45
- revokedAt: "2026-06-20T00:00:00.000Z",
46
- revocationReason: "cert_revoked",
47
- signature: { alg: "ed25519", kid: "cert-issuer-1", value: "x".repeat(86) },
48
- } as unknown as CertTombstone;
49
- }
50
-
51
- describe("isArtifactSuppressed (modal kill-switch)", () => {
52
- it("a live envelope (not revoked, not expired) → NOT suppressed", () => {
53
- expect(isArtifactSuppressed(envelope({}))).toBe(false);
54
- });
55
-
56
- it('a SLIM tombstone (kind: "tombstone", no payload) → suppressed (POR-10684)', () => {
57
- // The whole point of POR-10684: a slim tombstone has NO `payload.lifecycle`, so the old
58
- // payload-only read returned false and markSuppressed() never fired. `isTombstone` catches it.
59
- expect(isArtifactSuppressed(tombstone())).toBe(true);
60
- });
61
-
62
- it("a REVOKED envelope (legacy: revokedAt set) → suppressed", () => {
63
- expect(
64
- isArtifactSuppressed(envelope({ revokedAt: "2026-06-20T00:00:00.000Z" })),
65
- ).toBe(true);
66
- });
67
-
68
- it("an EXPIRED envelope (expiresAt in the past) → suppressed", () => {
69
- expect(
70
- isArtifactSuppressed(envelope({ expiresAt: "2020-01-01T00:00:00.000Z" })),
71
- ).toBe(true);
72
- });
73
-
74
- it("a missing/malformed artifact → NOT suppressed (fail-open; the 404 path handles absence)", () => {
75
- expect(isArtifactSuppressed(null)).toBe(false);
76
- expect(isArtifactSuppressed(undefined)).toBe(false);
77
- expect(
78
- isArtifactSuppressed({ payload: {} } as unknown as CertDeliveryArtifact),
79
- ).toBe(false);
80
- });
81
- });
82
-
83
- describe("<certrev-cert-modal> markSuppressed on a fetched slim tombstone (POR-10684)", () => {
84
- afterEach(() => {
85
- vi.unstubAllGlobals();
86
- document.body.innerHTML = "";
87
- });
88
-
89
- it("a fetched slim tombstone → marks the host suppressed AND hides the light-DOM triggers", async () => {
90
- // The Delivery API serves a slim tombstone (200, kind: 'tombstone', no payload). Before
91
- // POR-10684 the modal read `payload.lifecycle`, missed it, and never called markSuppressed().
92
- vi.stubGlobal(
93
- "fetch",
94
- vi.fn(
95
- async () =>
96
- ({ ok: true, json: async () => tombstone() }) as unknown as Response,
97
- ),
98
- );
99
-
100
- const el = document.createElement(CERT_MODAL_TAG) as HTMLElement & {
101
- open(kind: "cert" | "expert"): boolean;
102
- };
103
- el.setAttribute("data-delivery-api", "https://portal.certrev.com");
104
- el.setAttribute("data-platform", "shopify");
105
- el.setAttribute("data-external-id", "gid://shopify/Article/12345");
106
- el.setAttribute("data-fonts", "host"); // skip document-level font injection in the test
107
- document.body.appendChild(el);
108
-
109
- const trigger = document.createElement("a");
110
- trigger.setAttribute("data-certrev-modal-open", "cert");
111
- document.body.appendChild(trigger);
112
-
113
- const handled = el.open("cert");
114
- expect(handled).toBe(true); // it has a fetch source, so it takes over the open
115
- // The open() kicks off an un-awaited fetch; await the private in-flight promise it stored.
116
- await (el as unknown as { fetchInFlight: Promise<void> | null })
117
- .fetchInFlight;
118
-
119
- expect(el.hasAttribute("data-certrev-suppressed")).toBe(true);
120
- expect(trigger.hasAttribute("hidden")).toBe(true);
121
- expect(trigger.getAttribute("aria-hidden")).toBe("true");
122
- // A revoked cert never renders a dialog.
123
- expect(el.shadowRoot?.querySelector("dialog")).toBeNull();
124
- });
125
- });
@@ -1,153 +0,0 @@
1
- // @vitest-environment jsdom
2
- /**
3
- * Unit tests for the native FTC disclosure tamper guard (iframe→native parity for the MSA §3
4
- * anti-stripping kill switch). Pure DOM. The expected text is the single source of truth
5
- * `FTC_DISCLOSURE_LINE`, so these also pin the guard to the verbatim copy.
6
- */
7
-
8
- import { beforeEach, describe, expect, it } from "vitest";
9
- import { FTC_DISCLOSURE_LINE } from "./ftc-disclosure.js";
10
- import {
11
- enforceFtcDisclosure,
12
- FTC_NEUTRALIZED_ATTR,
13
- installFtcGuard,
14
- isDisclosureIntact,
15
- neutralizeMemo,
16
- } from "./ftc-guard.js";
17
-
18
- beforeEach(() => {
19
- document.head.innerHTML = "";
20
- document.body.innerHTML = "";
21
- });
22
-
23
- /** jsdom returns a 0×0 box for everything; stub a non-zero rect so the visibility check passes. */
24
- function makeVisible(el: Element): void {
25
- (el as HTMLElement).getBoundingClientRect = () =>
26
- ({
27
- width: 200,
28
- height: 18,
29
- top: 0,
30
- left: 0,
31
- right: 200,
32
- bottom: 18,
33
- x: 0,
34
- y: 0,
35
- toJSON() {},
36
- }) as DOMRect;
37
- }
38
-
39
- /** Mount a memo wrap + section carrying the verbatim disclosure line. Returns the line node too. */
40
- function mountMemo(lineText: string = FTC_DISCLOSURE_LINE): {
41
- wrap: HTMLElement;
42
- memo: HTMLElement;
43
- line: HTMLElement;
44
- } {
45
- const wrap = document.createElement("div");
46
- wrap.className = "certrev-memo-wrap";
47
- wrap.innerHTML = `
48
- <section class="certrev-memo">
49
- <p class="certrev-memo__ftc" data-ftc-disclosure><span data-ftc-line>${lineText}</span></p>
50
- </section>`;
51
- document.body.appendChild(wrap);
52
- const line = wrap.querySelector<HTMLElement>("[data-ftc-line]")!;
53
- makeVisible(line);
54
- return {
55
- wrap,
56
- memo: wrap.querySelector<HTMLElement>(".certrev-memo")!,
57
- line,
58
- };
59
- }
60
-
61
- describe("isDisclosureIntact", () => {
62
- it("true when the line is present, verbatim, and visible", () => {
63
- const { memo } = mountMemo();
64
- expect(isDisclosureIntact(memo)).toBe(true);
65
- });
66
-
67
- it("false when the disclosure line node is missing (stripped)", () => {
68
- const { memo, line } = mountMemo();
69
- line.remove();
70
- expect(isDisclosureIntact(memo)).toBe(false);
71
- });
72
-
73
- it("false when the text was altered (a single changed word)", () => {
74
- const { memo } = mountMemo(
75
- FTC_DISCLOSURE_LINE.replace("Independent", "Sponsored"),
76
- );
77
- expect(isDisclosureIntact(memo)).toBe(false);
78
- });
79
-
80
- it("false when the line is hidden via display:none", () => {
81
- const { memo, line } = mountMemo();
82
- line.style.display = "none";
83
- expect(isDisclosureIntact(memo)).toBe(false);
84
- });
85
-
86
- it("false when the line is hidden via visibility:hidden", () => {
87
- const { memo, line } = mountMemo();
88
- line.style.visibility = "hidden";
89
- expect(isDisclosureIntact(memo)).toBe(false);
90
- });
91
-
92
- it("false when the line is collapsed to a zero box (rect 0×0)", () => {
93
- const { memo, line } = mountMemo();
94
- (line as HTMLElement).getBoundingClientRect = () =>
95
- ({ width: 0, height: 0 }) as DOMRect;
96
- expect(isDisclosureIntact(memo)).toBe(false);
97
- });
98
- });
99
-
100
- describe("neutralizeMemo", () => {
101
- it("replaces the memo with the marked neutral notice (no attribution survives)", () => {
102
- const { memo } = mountMemo();
103
- memo.querySelector("span")!; // sanity: exists pre-neutralize
104
- neutralizeMemo(memo);
105
- expect(document.querySelector(".certrev-memo")).toBeNull();
106
- const notice = document.querySelector(`[${FTC_NEUTRALIZED_ATTR}]`);
107
- expect(notice).not.toBeNull();
108
- expect(notice!.textContent).toContain("required disclosure was not shown");
109
- });
110
- });
111
-
112
- describe("enforceFtcDisclosure", () => {
113
- it("leaves an intact memo untouched and returns true", () => {
114
- mountMemo();
115
- expect(enforceFtcDisclosure(document)).toBe(true);
116
- expect(document.querySelector(".certrev-memo")).not.toBeNull();
117
- expect(document.querySelector(`[${FTC_NEUTRALIZED_ATTR}]`)).toBeNull();
118
- });
119
-
120
- it("neutralizes a tampered memo and returns false", () => {
121
- const { line } = mountMemo();
122
- line.remove(); // strip the disclosure
123
- expect(enforceFtcDisclosure(document)).toBe(false);
124
- expect(document.querySelector(".certrev-memo")).toBeNull();
125
- expect(document.querySelector(`[${FTC_NEUTRALIZED_ATTR}]`)).not.toBeNull();
126
- });
127
-
128
- it("NO-OP (no false positive) when there is no memo at all — the no-memo certified path", () => {
129
- // A certified article without an expert memo renders no .certrev-memo (only the card cue).
130
- document.body.innerHTML =
131
- '<section class="certrev-badge certrev-card">card only, no memo</section>';
132
- expect(enforceFtcDisclosure(document)).toBe(true);
133
- expect(document.querySelector(`[${FTC_NEUTRALIZED_ATTR}]`)).toBeNull();
134
- expect(document.querySelector(".certrev-card")).not.toBeNull();
135
- });
136
- });
137
-
138
- describe("installFtcGuard", () => {
139
- it("enforces on install (document already interactive)", () => {
140
- const { line } = mountMemo();
141
- line.remove();
142
- installFtcGuard(document);
143
- expect(document.querySelector(`[${FTC_NEUTRALIZED_ATTR}]`)).not.toBeNull();
144
- });
145
-
146
- it("is idempotent — a second install does not double-bind / re-throw", () => {
147
- mountMemo();
148
- installFtcGuard(document);
149
- expect(() => installFtcGuard(document)).not.toThrow();
150
- // Still intact (the second call is a no-op; nothing neutralized a valid memo).
151
- expect(document.querySelector(".certrev-memo")).not.toBeNull();
152
- });
153
- });