@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.
- package/CHANGELOG.md +195 -12
- package/LICENSE +21 -0
- package/README.md +422 -50
- package/dist/builder/cert-chrome-data.d.ts +26 -8
- package/dist/builder/cert-chrome-data.d.ts.map +1 -1
- package/dist/builder/cert-chrome-data.js +5 -5
- package/dist/builder/cert-chrome-data.js.map +1 -1
- package/dist/builder/cert-review-card.d.ts +35 -5
- package/dist/builder/cert-review-card.d.ts.map +1 -1
- package/dist/builder/cert-review-card.js +115 -34
- package/dist/builder/cert-review-card.js.map +1 -1
- package/dist/builder/index.d.ts +33 -16
- package/dist/builder/index.d.ts.map +1 -1
- package/dist/builder/index.js +33 -16
- package/dist/builder/index.js.map +1 -1
- package/dist/components/CertBadge.d.ts +2 -2
- package/dist/components/CertBadge.d.ts.map +1 -1
- package/dist/components/CertBadge.js +11 -5
- package/dist/components/CertBadge.js.map +1 -1
- package/dist/components/CertJsonLd.d.ts.map +1 -1
- package/dist/components/CertJsonLd.js.map +1 -1
- package/dist/components/CertRevBacklink.d.ts.map +1 -1
- package/dist/components/CertRevBacklink.js +3 -2
- package/dist/components/CertRevBacklink.js.map +1 -1
- package/dist/components/CertReview.d.ts +1 -1
- package/dist/components/ExpertBio.d.ts.map +1 -1
- package/dist/components/ExpertBio.js +12 -4
- package/dist/components/ExpertBio.js.map +1 -1
- package/dist/components/escape.d.ts +28 -1
- package/dist/components/escape.d.ts.map +1 -1
- package/dist/components/escape.js +37 -8
- package/dist/components/escape.js.map +1 -1
- package/dist/components/format.d.ts +51 -2
- package/dist/components/format.d.ts.map +1 -1
- package/dist/components/format.js +103 -6
- package/dist/components/format.js.map +1 -1
- package/dist/components/preview-block.d.ts +1 -1
- package/dist/components/preview-block.js +3 -3
- package/dist/components/preview-block.js.map +1 -1
- package/dist/components/rel.d.ts +24 -0
- package/dist/components/rel.d.ts.map +1 -0
- package/dist/components/rel.js +24 -0
- package/dist/components/rel.js.map +1 -0
- package/dist/components/render-cert-block.d.ts +50 -22
- package/dist/components/render-cert-block.d.ts.map +1 -1
- package/dist/components/render-cert-block.js +146 -80
- package/dist/components/render-cert-block.js.map +1 -1
- package/dist/components/render-def.d.ts +13 -14
- package/dist/components/render-def.d.ts.map +1 -1
- package/dist/components/render-def.js +14 -15
- package/dist/components/render-def.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/jsonld/project.d.ts +46 -13
- package/dist/jsonld/project.d.ts.map +1 -1
- package/dist/jsonld/project.js +139 -56
- package/dist/jsonld/project.js.map +1 -1
- package/dist/modal/avatar-resize.d.ts +10 -10
- package/dist/modal/avatar-resize.js +14 -14
- package/dist/modal/avatar-resize.js.map +1 -1
- package/dist/modal/cert-modal-view.d.ts +27 -7
- package/dist/modal/cert-modal-view.d.ts.map +1 -1
- package/dist/modal/cert-modal-view.js +49 -25
- package/dist/modal/cert-modal-view.js.map +1 -1
- package/dist/modal/certrev-cert-modal.d.ts +5 -5
- package/dist/modal/certrev-cert-modal.js +9 -9
- package/dist/modal/certrev-cert-modal.js.map +1 -1
- package/dist/modal/certrev-cert-modal.styles.d.ts +6 -6
- package/dist/modal/certrev-cert-modal.styles.d.ts.map +1 -1
- package/dist/modal/certrev-cert-modal.styles.js +8 -8
- package/dist/modal/certrev-cert-modal.styles.js.map +1 -1
- package/dist/modal/certrev-cert.entry.d.ts +8 -6
- package/dist/modal/certrev-cert.entry.d.ts.map +1 -1
- package/dist/modal/certrev-cert.entry.js +9 -7
- package/dist/modal/certrev-cert.entry.js.map +1 -1
- package/dist/modal/certrev-cert.js +6 -6
- package/dist/modal/display-strings.d.ts +25 -5
- package/dist/modal/display-strings.d.ts.map +1 -1
- package/dist/modal/display-strings.js +27 -5
- package/dist/modal/display-strings.js.map +1 -1
- package/dist/modal/escape.d.ts +26 -3
- package/dist/modal/escape.d.ts.map +1 -1
- package/dist/modal/escape.js +32 -3
- package/dist/modal/escape.js.map +1 -1
- package/dist/modal/font-face.d.ts.map +1 -1
- package/dist/modal/font-face.js +8 -11
- package/dist/modal/font-face.js.map +1 -1
- package/dist/modal/ftc-disclosure.d.ts +2 -2
- package/dist/modal/ftc-disclosure.js +2 -2
- package/dist/modal/ftc-guard.d.ts +44 -9
- package/dist/modal/ftc-guard.d.ts.map +1 -1
- package/dist/modal/ftc-guard.js +50 -17
- package/dist/modal/ftc-guard.js.map +1 -1
- package/dist/modal/index.d.ts +1 -1
- package/dist/modal/index.js +1 -1
- package/dist/modal/interactions.d.ts +2 -2
- package/dist/modal/interactions.js +2 -2
- package/dist/modal/logo-paths.js +2 -2
- package/dist/modal/logo-paths.js.map +1 -1
- package/dist/modal/placement.d.ts +4 -3
- package/dist/modal/placement.d.ts.map +1 -1
- package/dist/modal/placement.js +7 -6
- package/dist/modal/placement.js.map +1 -1
- package/dist/modal/revalidate.d.ts +2 -3
- package/dist/modal/revalidate.d.ts.map +1 -1
- package/dist/modal/revalidate.js +2 -3
- package/dist/modal/revalidate.js.map +1 -1
- package/dist/modal/verify-client.d.ts +2 -2
- package/dist/modal/verify-client.js +2 -2
- package/dist/verify/cache.d.ts +21 -6
- package/dist/verify/cache.d.ts.map +1 -1
- package/dist/verify/cache.js +29 -8
- package/dist/verify/cache.js.map +1 -1
- package/dist/verify/fetch-with-deadline.d.ts +35 -0
- package/dist/verify/fetch-with-deadline.d.ts.map +1 -0
- package/dist/verify/fetch-with-deadline.js +55 -0
- package/dist/verify/fetch-with-deadline.js.map +1 -0
- package/dist/verify/get-verified-envelope.d.ts +23 -9
- package/dist/verify/get-verified-envelope.d.ts.map +1 -1
- package/dist/verify/get-verified-envelope.js +89 -40
- package/dist/verify/get-verified-envelope.js.map +1 -1
- package/dist/verify/resolve-kid.d.ts +3 -0
- package/dist/verify/resolve-kid.d.ts.map +1 -1
- package/dist/verify/resolve-kid.js +17 -8
- package/dist/verify/resolve-kid.js.map +1 -1
- package/dist/webcomponent/certrev-badge.d.ts.map +1 -1
- package/dist/webcomponent/certrev-badge.js +24 -2
- package/dist/webcomponent/certrev-badge.js.map +1 -1
- package/dist/webcomponent/render-badge-html.d.ts +1 -1
- package/dist/webcomponent/render-badge-html.d.ts.map +1 -1
- package/dist/webcomponent/render-badge-html.js +36 -17
- package/dist/webcomponent/render-badge-html.js.map +1 -1
- package/package.json +14 -2
- package/src/builder/cert-chrome-data.ts +26 -8
- package/src/builder/cert-review-card.tsx +147 -38
- package/src/builder/index.tsx +34 -17
- package/src/components/CertBadge.tsx +14 -9
- package/src/components/CertJsonLd.tsx +1 -1
- package/src/components/CertRevBacklink.tsx +10 -3
- package/src/components/CertReview.tsx +1 -1
- package/src/components/ExpertBio.tsx +12 -7
- package/src/components/escape.ts +37 -7
- package/src/components/format.ts +102 -6
- package/src/components/preview-block.ts +3 -3
- package/src/components/rel.ts +23 -0
- package/src/components/render-cert-block.ts +192 -109
- package/src/components/render-def.ts +14 -15
- package/src/index.ts +4 -2
- package/src/jsonld/project.ts +155 -59
- package/src/modal/avatar-resize.ts +14 -14
- package/src/modal/cert-modal-view.ts +52 -27
- package/src/modal/certrev-cert-modal.styles.ts +8 -8
- package/src/modal/certrev-cert-modal.ts +9 -9
- package/src/modal/certrev-cert.entry.ts +9 -7
- package/src/modal/display-strings.ts +26 -5
- package/src/modal/escape.ts +32 -4
- package/src/modal/font-face.ts +8 -11
- package/src/modal/ftc-disclosure.ts +2 -2
- package/src/modal/ftc-guard.ts +73 -18
- package/src/modal/index.ts +1 -1
- package/src/modal/interactions.ts +2 -2
- package/src/modal/logo-paths.ts +2 -2
- package/src/modal/placement.ts +7 -6
- package/src/modal/revalidate.ts +2 -3
- package/src/modal/verify-client.ts +2 -2
- package/src/verify/cache.ts +34 -8
- package/src/verify/fetch-with-deadline.ts +60 -0
- package/src/verify/get-verified-envelope.ts +131 -48
- package/src/verify/resolve-kid.ts +22 -6
- package/src/webcomponent/certrev-badge.ts +24 -2
- package/src/webcomponent/render-badge-html.ts +66 -19
- package/src/__tests__/__snapshots__/render-cert-block.test.ts.snap +0 -17
- package/src/__tests__/components.test.tsx +0 -206
- package/src/__tests__/preview-block.test.ts +0 -84
- package/src/__tests__/project.test.ts +0 -128
- package/src/__tests__/render-cert-block.test.ts +0 -657
- package/src/__tests__/render-def.test.tsx +0 -295
- package/src/__tests__/verify.test.ts +0 -239
- package/src/__tests__/webcomponent.test.tsx +0 -116
- package/src/builder/__tests__/builder.test.tsx +0 -158
- package/src/modal/cert-modal-view.test.ts +0 -175
- package/src/modal/certrev-cert-modal.test.ts +0 -125
- package/src/modal/ftc-guard.test.ts +0 -153
- package/src/modal/interactions.test.ts +0 -250
- package/src/modal/placement.test.ts +0 -398
- package/src/modal/revalidate.test.ts +0 -111
- package/src/modal/verify-client.test.ts +0 -164
|
@@ -1,295 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* WS6 — design-guide render-def support (themeable tokens + subtractive visibility)
|
|
3
|
-
* + the two always-on compliance strings.
|
|
4
|
-
*
|
|
5
|
-
* Two planes, both exercised through the SSR paths (React `renderToStaticMarkup` +
|
|
6
|
-
* the framework-agnostic `renderBadgeHtml` string renderer — the crawlable output):
|
|
7
|
-
* 1. the PURE resolution helpers (validators, `resolveRenderTheme`, visibility,
|
|
8
|
-
* the fixed font stacks, the byte-verbatim strings);
|
|
9
|
-
* 2. the RENDERED faces — a themed def emits the four `--certrev-*` vars + the
|
|
10
|
-
* def-version stamp + subtractive visibility; NO def is theme-byte-identical
|
|
11
|
-
* to the un-themed default; an invalid token is DROPPED (fail-safe); and the
|
|
12
|
-
* compensation cue + FTC scope line + strings-version stamp land on EVERY
|
|
13
|
-
* claim face (the compliance baseline, with or without a def).
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { renderToStaticMarkup } from 'react-dom/server'
|
|
17
|
-
import { describe, expect, it } from 'vitest'
|
|
18
|
-
import { CertBadge } from '../components/CertBadge.js'
|
|
19
|
-
import { CertReview } from '../components/CertReview.js'
|
|
20
|
-
import {
|
|
21
|
-
CERT_SCOPE_LINE,
|
|
22
|
-
CERT_STRINGS_VERSION,
|
|
23
|
-
type CertRenderDef,
|
|
24
|
-
COMPENSATED_EXPERT_CUE,
|
|
25
|
-
type FontSlot,
|
|
26
|
-
FONT_STACKS,
|
|
27
|
-
isCornerRadius,
|
|
28
|
-
isFieldHidden,
|
|
29
|
-
isFontSlot,
|
|
30
|
-
isOpaqueHexColor,
|
|
31
|
-
resolveRenderTheme,
|
|
32
|
-
themeCssVarDeclarations,
|
|
33
|
-
} from '../components/render-def.js'
|
|
34
|
-
import { makeMockPayload } from '../contract/fixtures.js'
|
|
35
|
-
import { renderBadgeHtml } from '../webcomponent/render-badge-html.js'
|
|
36
|
-
|
|
37
|
-
const payload = makeMockPayload()
|
|
38
|
-
|
|
39
|
-
/** A fully-themed, valid render-def (every token overridden, both fields hidden). */
|
|
40
|
-
const THEMED: CertRenderDef = {
|
|
41
|
-
tokens: { accentColor: '#123456', surface: '#eeeeee', cornerRadius: '8px', fontSlot: 'serif' },
|
|
42
|
-
hidden: ['reviewerPhoto', 'memo'],
|
|
43
|
-
version: 4,
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Tag-strip a fragment of HTML to its visible text (collapse whitespace). */
|
|
47
|
-
function visibleText(html: string): string {
|
|
48
|
-
return html
|
|
49
|
-
.replace(/<[^>]+>/g, ' ')
|
|
50
|
-
.replace(/\s+/g, ' ')
|
|
51
|
-
.trim()
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
55
|
-
// Pure helpers
|
|
56
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
57
|
-
|
|
58
|
-
describe('token validators (mirror portal src/lib/design-guide/tokens.ts)', () => {
|
|
59
|
-
it('isOpaqueHexColor accepts 3/6-digit hex, rejects alpha / functions / keywords', () => {
|
|
60
|
-
for (const ok of ['#fff', '#FFFFFF', '#0a1b3f', '#123']) expect(isOpaqueHexColor(ok)).toBe(true)
|
|
61
|
-
for (const bad of ['#ffff', '#ffffffaa', '#12', '#1234567', 'transparent', 'rgb(0,0,0)', 'red', '', ' #fff'])
|
|
62
|
-
expect(isOpaqueHexColor(bad)).toBe(false)
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
it('isCornerRadius accepts a bare 0 or a single unit length, rejects unitless / calc / spaces', () => {
|
|
66
|
-
for (const ok of ['0', '12px', '0.5rem', '.5em', '100%', '4px']) expect(isCornerRadius(ok)).toBe(true)
|
|
67
|
-
for (const bad of ['12', '12 px', 'calc(1px)', '1px 2px', '', 'px', '-4px']) expect(isCornerRadius(bad)).toBe(false)
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
it('isFontSlot admits the five enum members', () => {
|
|
71
|
-
for (const ok of ['sans', 'serif', 'system', 'grotesk', 'mono']) expect(isFontSlot(ok)).toBe(true)
|
|
72
|
-
for (const bad of ['Arial', '', 'SANS', 'wingdings']) expect(isFontSlot(bad)).toBe(false)
|
|
73
|
-
})
|
|
74
|
-
})
|
|
75
|
-
|
|
76
|
-
describe('FONT_STACKS — byte-identical to the Shopify Liquid engine literals', () => {
|
|
77
|
-
it('matches the exact stacks (the parity anchor across the twins)', () => {
|
|
78
|
-
expect(FONT_STACKS.sans).toBe("'DM Sans', system-ui, -apple-system, BlinkMacSystemFont, sans-serif")
|
|
79
|
-
expect(FONT_STACKS.serif).toBe("'Playfair Display', Georgia, 'Times New Roman', serif")
|
|
80
|
-
expect(FONT_STACKS.system).toBe("system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif")
|
|
81
|
-
// grotesk/mono added at the shared guide values — byte-identical to RENDER_BLOCK_FONT_STACKS
|
|
82
|
-
// and the portal FONT_SLOT_STACKS, so the modal + Liquid card paint the same typography.
|
|
83
|
-
expect(FONT_STACKS.grotesk).toBe("'Plus Jakarta Sans', system-ui, Helvetica, Arial, sans-serif")
|
|
84
|
-
expect(FONT_STACKS.mono).toBe("'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace")
|
|
85
|
-
})
|
|
86
|
-
})
|
|
87
|
-
|
|
88
|
-
describe('resolveRenderTheme — validates + drops (fail-safe)', () => {
|
|
89
|
-
it('returns {} for an absent def', () => {
|
|
90
|
-
expect(resolveRenderTheme(undefined)).toEqual({})
|
|
91
|
-
})
|
|
92
|
-
|
|
93
|
-
it('resolves every valid token + the fontSlot → fixed stack + the version', () => {
|
|
94
|
-
expect(resolveRenderTheme(THEMED)).toEqual({
|
|
95
|
-
accent: '#123456',
|
|
96
|
-
surface: '#eeeeee',
|
|
97
|
-
cornerRadius: '8px',
|
|
98
|
-
fontStack: FONT_STACKS.serif,
|
|
99
|
-
version: 4,
|
|
100
|
-
})
|
|
101
|
-
})
|
|
102
|
-
|
|
103
|
-
it('resolves the ADDED grotesk/mono slots to their FONT_STACKS families (the modal themes all five)', () => {
|
|
104
|
-
expect(resolveRenderTheme({ tokens: { fontSlot: 'grotesk' } }).fontStack).toBe(FONT_STACKS.grotesk)
|
|
105
|
-
expect(resolveRenderTheme({ tokens: { fontSlot: 'mono' } }).fontStack).toBe(FONT_STACKS.mono)
|
|
106
|
-
})
|
|
107
|
-
|
|
108
|
-
it('DROPS every invalid token (alpha surface, function accent, calc radius, unknown slot)', () => {
|
|
109
|
-
const resolved = resolveRenderTheme({
|
|
110
|
-
tokens: {
|
|
111
|
-
accentColor: 'rgb(0,0,0)',
|
|
112
|
-
surface: '#ffffffaa',
|
|
113
|
-
cornerRadius: 'calc(1px)',
|
|
114
|
-
fontSlot: 'wingdings' as FontSlot,
|
|
115
|
-
},
|
|
116
|
-
version: 2,
|
|
117
|
-
})
|
|
118
|
-
expect(resolved).toEqual({
|
|
119
|
-
accent: undefined,
|
|
120
|
-
surface: undefined,
|
|
121
|
-
cornerRadius: undefined,
|
|
122
|
-
fontStack: undefined,
|
|
123
|
-
version: 2,
|
|
124
|
-
})
|
|
125
|
-
})
|
|
126
|
-
|
|
127
|
-
it('drops a non-finite version', () => {
|
|
128
|
-
expect(resolveRenderTheme({ version: Number.NaN }).version).toBeUndefined()
|
|
129
|
-
})
|
|
130
|
-
})
|
|
131
|
-
|
|
132
|
-
describe('subtractive visibility + var declarations', () => {
|
|
133
|
-
it('isFieldHidden is true only for a listed field', () => {
|
|
134
|
-
expect(isFieldHidden({ hidden: ['memo'] }, 'memo')).toBe(true)
|
|
135
|
-
expect(isFieldHidden({ hidden: ['memo'] }, 'reviewerPhoto')).toBe(false)
|
|
136
|
-
expect(isFieldHidden(undefined, 'memo')).toBe(false)
|
|
137
|
-
})
|
|
138
|
-
|
|
139
|
-
it('themeCssVarDeclarations is empty for an un-themed face and ordered surface→radius→font', () => {
|
|
140
|
-
expect(themeCssVarDeclarations({})).toEqual([])
|
|
141
|
-
expect(themeCssVarDeclarations(resolveRenderTheme(THEMED))).toEqual([
|
|
142
|
-
['--certrev-surface', '#eeeeee'],
|
|
143
|
-
['--certrev-radius', '8px'],
|
|
144
|
-
['--certrev-font', FONT_STACKS.serif],
|
|
145
|
-
])
|
|
146
|
-
})
|
|
147
|
-
})
|
|
148
|
-
|
|
149
|
-
describe('the two always-on compliance strings are byte-verbatim', () => {
|
|
150
|
-
it('cue + strings-version', () => {
|
|
151
|
-
expect(COMPENSATED_EXPERT_CUE).toBe('Compensated expert')
|
|
152
|
-
expect(CERT_STRINGS_VERSION).toBe('2026-07')
|
|
153
|
-
})
|
|
154
|
-
|
|
155
|
-
it('the scope line carries the load-bearing curly apostrophe U+2019', () => {
|
|
156
|
-
expect(CERT_SCOPE_LINE).toBe(
|
|
157
|
-
'Independent editorial review of this article’s text and sources. Not a product endorsement, nor medical advice.',
|
|
158
|
-
)
|
|
159
|
-
expect(CERT_SCOPE_LINE).toContain('’')
|
|
160
|
-
expect(CERT_SCOPE_LINE).not.toContain("'") // straight apostrophe would be a contract divergence
|
|
161
|
-
})
|
|
162
|
-
})
|
|
163
|
-
|
|
164
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
165
|
-
// Rendered — React <CertBadge>
|
|
166
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
167
|
-
|
|
168
|
-
describe('<CertBadge> themed', () => {
|
|
169
|
-
it('emits the four --certrev-* vars, the def-version stamp, and hides subtracted fields', () => {
|
|
170
|
-
const html = renderToStaticMarkup(<CertBadge payload={payload} renderDef={THEMED} />)
|
|
171
|
-
expect(html).toContain('--certrev-accent:#123456')
|
|
172
|
-
expect(html).toContain('--certrev-surface:#eeeeee')
|
|
173
|
-
expect(html).toContain('--certrev-radius:8px')
|
|
174
|
-
// React entity-encodes the font stack's single quotes; the family content is intact.
|
|
175
|
-
expect(html).toContain('--certrev-font:')
|
|
176
|
-
expect(html).toContain('Playfair Display')
|
|
177
|
-
expect(html).toContain('data-certrev-def-version="4"')
|
|
178
|
-
// subtractive visibility: reviewerPhoto + memo hidden
|
|
179
|
-
expect(html).not.toContain('__photo')
|
|
180
|
-
expect(html).not.toContain('retinol claims')
|
|
181
|
-
})
|
|
182
|
-
|
|
183
|
-
it('an unthemed badge emits NO theme vars + NO def-version stamp (theme byte-neutral)', () => {
|
|
184
|
-
const html = renderToStaticMarkup(<CertBadge payload={payload} />)
|
|
185
|
-
expect(html).not.toContain('--certrev-surface')
|
|
186
|
-
expect(html).not.toContain('--certrev-radius')
|
|
187
|
-
expect(html).not.toContain('--certrev-font')
|
|
188
|
-
expect(html).not.toContain('data-certrev-def-version')
|
|
189
|
-
})
|
|
190
|
-
})
|
|
191
|
-
|
|
192
|
-
describe('compliance baseline — cue + scope + strings-version on EVERY claim face', () => {
|
|
193
|
-
it('full + unthemed: cue, verbatim scope line, and the strings-version stamp all present', () => {
|
|
194
|
-
const html = renderToStaticMarkup(<CertBadge payload={payload} />)
|
|
195
|
-
expect(html).toContain('data-certrev-strings-version="2026-07"')
|
|
196
|
-
expect(html).toContain(CERT_SCOPE_LINE)
|
|
197
|
-
expect(html).toContain('data-ftc-line')
|
|
198
|
-
expect(visibleText(html)).toContain(COMPENSATED_EXPERT_CUE)
|
|
199
|
-
})
|
|
200
|
-
|
|
201
|
-
it('compact ALSO carries the cue + scope (a compact claim badge is still a claim face)', () => {
|
|
202
|
-
const html = renderToStaticMarkup(<CertBadge payload={payload} badgeStyle="compact" />)
|
|
203
|
-
expect(html).toContain('certrev-badge--compact')
|
|
204
|
-
expect(html).toContain(CERT_SCOPE_LINE)
|
|
205
|
-
expect(visibleText(html)).toContain(COMPENSATED_EXPERT_CUE)
|
|
206
|
-
})
|
|
207
|
-
|
|
208
|
-
it('the cue links "expert" to the reviewer profile; falls back to plain text when unsafe', () => {
|
|
209
|
-
const linked = renderToStaticMarkup(<CertBadge payload={payload} />)
|
|
210
|
-
expect(linked).toContain('>expert</a>')
|
|
211
|
-
const p = makeMockPayload({
|
|
212
|
-
content: { ...payload.content, expert: { ...payload.content.expert, profileUrl: 'javascript:alert(1)' } },
|
|
213
|
-
})
|
|
214
|
-
const unlinked = renderToStaticMarkup(<CertBadge payload={p} />)
|
|
215
|
-
expect(unlinked).not.toContain('javascript:')
|
|
216
|
-
expect(visibleText(unlinked)).toContain(COMPENSATED_EXPERT_CUE)
|
|
217
|
-
})
|
|
218
|
-
})
|
|
219
|
-
|
|
220
|
-
describe('<CertReview> forwards the render-def to the badge', () => {
|
|
221
|
-
it('a themed def reaches the badge; the JSON-LD stays presentation-blind', () => {
|
|
222
|
-
const html = renderToStaticMarkup(<CertReview verdict={{ decision: 'render', payload }} renderDef={THEMED} />)
|
|
223
|
-
expect(html).toContain('--certrev-surface:#eeeeee')
|
|
224
|
-
expect(html).toContain('data-certrev-def-version="4"')
|
|
225
|
-
// the projector never sees the def — the graph is byte-unchanged
|
|
226
|
-
expect(html).toContain('application/ld+json')
|
|
227
|
-
})
|
|
228
|
-
})
|
|
229
|
-
|
|
230
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
231
|
-
// Rendered — the framework-agnostic string path (renderBadgeHtml)
|
|
232
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
233
|
-
|
|
234
|
-
describe('renderBadgeHtml themed (the crawlable string path)', () => {
|
|
235
|
-
it('emits each --certrev-* var exactly once; the font stack is the RAW fixed literal (byte-exact)', () => {
|
|
236
|
-
const html = renderBadgeHtml(payload, { renderDef: THEMED })
|
|
237
|
-
expect(html).toContain('--certrev-accent:#123456')
|
|
238
|
-
expect(html).toContain('--certrev-surface:#eeeeee')
|
|
239
|
-
expect(html).toContain('--certrev-radius:8px')
|
|
240
|
-
expect(html).toContain(`--certrev-font:${FONT_STACKS.serif}`) // byte-exact, single quotes raw
|
|
241
|
-
for (const v of ['--certrev-accent:', '--certrev-surface:', '--certrev-radius:', '--certrev-font:'])
|
|
242
|
-
expect((html.match(new RegExp(v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) ?? []).length).toBe(1)
|
|
243
|
-
expect(html).toContain('data-certrev-def-version="4"')
|
|
244
|
-
expect(html).toContain('data-certrev-strings-version="2026-07"')
|
|
245
|
-
expect(html).not.toContain('__photo') // reviewerPhoto hidden
|
|
246
|
-
expect(html).not.toContain('__memo') // memo hidden
|
|
247
|
-
})
|
|
248
|
-
|
|
249
|
-
it('the compliance baseline is present: verbatim scope line + the cue', () => {
|
|
250
|
-
const html = renderBadgeHtml(payload, { renderDef: THEMED })
|
|
251
|
-
expect(html).toContain(CERT_SCOPE_LINE)
|
|
252
|
-
expect(html).toContain('data-ftc-line')
|
|
253
|
-
expect(visibleText(html)).toContain(COMPENSATED_EXPERT_CUE)
|
|
254
|
-
})
|
|
255
|
-
|
|
256
|
-
it('DROPS every invalid token (fail-safe to preset default); no injection reaches the style', () => {
|
|
257
|
-
const html = renderBadgeHtml(payload, {
|
|
258
|
-
renderDef: {
|
|
259
|
-
tokens: {
|
|
260
|
-
accentColor: 'rgb(0,0,0)',
|
|
261
|
-
surface: '#ffffffaa',
|
|
262
|
-
cornerRadius: 'calc(1px)',
|
|
263
|
-
fontSlot: 'wingdings' as FontSlot,
|
|
264
|
-
},
|
|
265
|
-
},
|
|
266
|
-
})
|
|
267
|
-
expect(html).not.toContain('rgb(0,0,0)')
|
|
268
|
-
expect(html).not.toContain('#ffffffaa')
|
|
269
|
-
expect(html).not.toContain('calc(')
|
|
270
|
-
expect(html).not.toContain('--certrev-surface')
|
|
271
|
-
expect(html).not.toContain('--certrev-radius')
|
|
272
|
-
expect(html).not.toContain('--certrev-font')
|
|
273
|
-
// the invalid accent fell back to the signed display accent (never the rgb)
|
|
274
|
-
expect(html).toContain('--certrev-accent:#7c3aed')
|
|
275
|
-
})
|
|
276
|
-
|
|
277
|
-
it('un-themed: theme output is BYTE-IDENTICAL to the pre-WS6 single-accent form', () => {
|
|
278
|
-
const html = renderBadgeHtml(payload)
|
|
279
|
-
expect(html).toContain('style="--certrev-accent:#7c3aed;border-inline-start-color:#7c3aed"')
|
|
280
|
-
expect(html).not.toContain('--certrev-surface')
|
|
281
|
-
expect(html).not.toContain('data-certrev-def-version')
|
|
282
|
-
// baseline compliance strings still land on the un-themed face
|
|
283
|
-
expect(html).toContain('data-certrev-strings-version="2026-07"')
|
|
284
|
-
expect(html).toContain(CERT_SCOPE_LINE)
|
|
285
|
-
})
|
|
286
|
-
|
|
287
|
-
it('renders the cue as plain text when the profile URL is unsafe/absent', () => {
|
|
288
|
-
const p = makeMockPayload({
|
|
289
|
-
content: { ...payload.content, expert: { ...payload.content.expert, profileUrl: 'javascript:alert(1)' } },
|
|
290
|
-
})
|
|
291
|
-
const html = renderBadgeHtml(p)
|
|
292
|
-
expect(html).not.toContain('javascript:')
|
|
293
|
-
expect(html).toContain('Compensated expert')
|
|
294
|
-
})
|
|
295
|
-
})
|
|
@@ -1,239 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
-
import { makeSignedEnvelope } from '../contract/fixtures.js'
|
|
3
|
-
import type { CertDeliveryEnvelope } from '../contract/kernel.js'
|
|
4
|
-
import { verifyEnvelope } from '../contract/kernel.js'
|
|
5
|
-
import { TtlCache } from '../verify/cache.js'
|
|
6
|
-
import { getVerifiedEnvelope } from '../verify/get-verified-envelope.js'
|
|
7
|
-
import { staticKidResolver } from '../verify/resolve-kid.js'
|
|
8
|
-
|
|
9
|
-
const RENDER_CTX = { platform: 'shopify', externalId: 'gid://shopify/Article/123456789' }
|
|
10
|
-
|
|
11
|
-
describe('kernel via the SDK binding (real Ed25519 over JCS)', () => {
|
|
12
|
-
it('a freshly signed fixture envelope verifies + renders, carrying the payload', async () => {
|
|
13
|
-
const { envelope, resolveKid } = makeSignedEnvelope()
|
|
14
|
-
const verdict = await verifyEnvelope(envelope, resolveKid, { ...RENDER_CTX, now: new Date('2026-06-22T00:00:00Z') })
|
|
15
|
-
expect(verdict.decision).toBe('render')
|
|
16
|
-
if (verdict.decision === 'render') expect(verdict.payload.certId).toBe('cert_fixture_001')
|
|
17
|
-
})
|
|
18
|
-
|
|
19
|
-
it('a tampered payload fails the signature (fail-closed suppress)', async () => {
|
|
20
|
-
const { envelope, resolveKid } = makeSignedEnvelope()
|
|
21
|
-
const tampered: CertDeliveryEnvelope = { ...envelope, payload: { ...envelope.payload, certId: 'cert_FORGED' } }
|
|
22
|
-
expect(await verifyEnvelope(tampered, resolveKid, RENDER_CTX)).toEqual({
|
|
23
|
-
decision: 'suppress',
|
|
24
|
-
reason: 'invalid_signature',
|
|
25
|
-
})
|
|
26
|
-
})
|
|
27
|
-
|
|
28
|
-
it('an unknown kid suppresses unknown_key', async () => {
|
|
29
|
-
const { envelope } = makeSignedEnvelope()
|
|
30
|
-
const verdict = await verifyEnvelope(envelope, () => null, RENDER_CTX)
|
|
31
|
-
expect(verdict).toEqual({ decision: 'suppress', reason: 'unknown_key' })
|
|
32
|
-
})
|
|
33
|
-
|
|
34
|
-
it('platform mismatch suppresses', async () => {
|
|
35
|
-
const { envelope, resolveKid } = makeSignedEnvelope()
|
|
36
|
-
expect(await verifyEnvelope(envelope, resolveKid, { ...RENDER_CTX, platform: 'wordpress' })).toEqual({
|
|
37
|
-
decision: 'suppress',
|
|
38
|
-
reason: 'platform_mismatch',
|
|
39
|
-
})
|
|
40
|
-
})
|
|
41
|
-
|
|
42
|
-
it('revoked suppresses', async () => {
|
|
43
|
-
const { envelope, resolveKid } = makeSignedEnvelope({
|
|
44
|
-
lifecycle: {
|
|
45
|
-
issuedAt: '2026-06-21T00:00:00Z',
|
|
46
|
-
expiresAt: '2099-01-01T00:00:00Z',
|
|
47
|
-
revokedAt: '2026-06-21T12:00:00Z',
|
|
48
|
-
revision: 2,
|
|
49
|
-
},
|
|
50
|
-
})
|
|
51
|
-
expect((await verifyEnvelope(envelope, resolveKid, RENDER_CTX)).decision).toBe('suppress')
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
it('expired suppresses', async () => {
|
|
55
|
-
const { envelope, resolveKid } = makeSignedEnvelope({
|
|
56
|
-
lifecycle: { issuedAt: '2026-01-01T00:00:00Z', expiresAt: '2026-02-01T00:00:00Z', revokedAt: null, revision: 1 },
|
|
57
|
-
})
|
|
58
|
-
const verdict = await verifyEnvelope(envelope, resolveKid, { ...RENDER_CTX, now: new Date('2026-03-01T00:00:00Z') })
|
|
59
|
-
expect(verdict).toEqual({ decision: 'suppress', reason: 'expired' })
|
|
60
|
-
})
|
|
61
|
-
|
|
62
|
-
it('content drift (live hash != subject.contentDigest) suppresses', async () => {
|
|
63
|
-
const { envelope, resolveKid } = makeSignedEnvelope()
|
|
64
|
-
const verdict = await verifyEnvelope(envelope, resolveKid, { ...RENDER_CTX, liveContentHash: 'b'.repeat(64) })
|
|
65
|
-
expect(verdict).toEqual({ decision: 'suppress', reason: 'content_drift' })
|
|
66
|
-
})
|
|
67
|
-
|
|
68
|
-
it('matching live hash renders', async () => {
|
|
69
|
-
const digest = 'c'.repeat(64)
|
|
70
|
-
const { envelope, resolveKid } = makeSignedEnvelope({
|
|
71
|
-
subject: { ...makeSignedEnvelope().envelope.payload.subject, contentDigest: digest },
|
|
72
|
-
})
|
|
73
|
-
const verdict = await verifyEnvelope(envelope, resolveKid, { ...RENDER_CTX, liveContentHash: digest })
|
|
74
|
-
expect(verdict.decision).toBe('render')
|
|
75
|
-
})
|
|
76
|
-
})
|
|
77
|
-
|
|
78
|
-
describe('getVerifiedEnvelope — metafield source', () => {
|
|
79
|
-
it('verifies an envelope read from a metafield (object form)', async () => {
|
|
80
|
-
const { envelope, resolveKid } = makeSignedEnvelope()
|
|
81
|
-
const verdict = await getVerifiedEnvelope({
|
|
82
|
-
source: { kind: 'metafield', value: envelope },
|
|
83
|
-
resolveKid,
|
|
84
|
-
context: { ...RENDER_CTX, now: new Date('2026-06-22T00:00:00Z') },
|
|
85
|
-
cache: new TtlCache(),
|
|
86
|
-
})
|
|
87
|
-
expect(verdict.decision).toBe('render')
|
|
88
|
-
})
|
|
89
|
-
|
|
90
|
-
it('verifies an envelope read from a metafield (JSON-string form)', async () => {
|
|
91
|
-
const { envelope, resolveKid } = makeSignedEnvelope()
|
|
92
|
-
const verdict = await getVerifiedEnvelope({
|
|
93
|
-
source: { kind: 'metafield', value: JSON.stringify(envelope) },
|
|
94
|
-
resolveKid,
|
|
95
|
-
context: { ...RENDER_CTX, now: new Date('2026-06-22T00:00:00Z') },
|
|
96
|
-
cache: new TtlCache(),
|
|
97
|
-
})
|
|
98
|
-
expect(verdict.decision).toBe('render')
|
|
99
|
-
})
|
|
100
|
-
|
|
101
|
-
it('a missing metafield value fails closed to suppress', async () => {
|
|
102
|
-
const { resolveKid } = makeSignedEnvelope()
|
|
103
|
-
const verdict = await getVerifiedEnvelope({
|
|
104
|
-
source: { kind: 'metafield', value: null },
|
|
105
|
-
resolveKid,
|
|
106
|
-
context: RENDER_CTX,
|
|
107
|
-
cache: new TtlCache(),
|
|
108
|
-
})
|
|
109
|
-
expect(verdict.decision).toBe('suppress')
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
it('malformed JSON in a metafield fails closed (no throw)', async () => {
|
|
113
|
-
const { resolveKid } = makeSignedEnvelope()
|
|
114
|
-
const verdict = await getVerifiedEnvelope({
|
|
115
|
-
source: { kind: 'metafield', value: '{not json' },
|
|
116
|
-
resolveKid,
|
|
117
|
-
context: RENDER_CTX,
|
|
118
|
-
cache: new TtlCache(),
|
|
119
|
-
})
|
|
120
|
-
expect(verdict.decision).toBe('suppress')
|
|
121
|
-
})
|
|
122
|
-
|
|
123
|
-
it('DISTINCT metafield envelopes at one placement do NOT collide on a shared cache (value-keyed)', async () => {
|
|
124
|
-
// Regression: the metafield cache key must include a hash of the envelope value, else
|
|
125
|
-
// the first verdict (render) is served for a later, DIFFERENT envelope — a fail-closed
|
|
126
|
-
// violation (an unverified/tampered credential rendered). Here a valid + a tampered
|
|
127
|
-
// envelope share the SAME resolver, context, and cache; each must get its own verdict.
|
|
128
|
-
const { envelope, resolveKid } = makeSignedEnvelope()
|
|
129
|
-
const tampered: CertDeliveryEnvelope = {
|
|
130
|
-
...envelope,
|
|
131
|
-
payload: {
|
|
132
|
-
...envelope.payload,
|
|
133
|
-
content: {
|
|
134
|
-
...envelope.payload.content,
|
|
135
|
-
expert: { ...envelope.payload.content.expert, displayName: 'Dr. Forged' },
|
|
136
|
-
},
|
|
137
|
-
},
|
|
138
|
-
}
|
|
139
|
-
const cache = new TtlCache<import('../contract/kernel.js').CertVerdict>()
|
|
140
|
-
const ctx = { ...RENDER_CTX, now: new Date('2026-06-22T00:00:00Z') }
|
|
141
|
-
|
|
142
|
-
const validVerdict = await getVerifiedEnvelope({
|
|
143
|
-
source: { kind: 'metafield', value: envelope },
|
|
144
|
-
resolveKid,
|
|
145
|
-
context: ctx,
|
|
146
|
-
cache,
|
|
147
|
-
})
|
|
148
|
-
const tamperedVerdict = await getVerifiedEnvelope({
|
|
149
|
-
source: { kind: 'metafield', value: tampered },
|
|
150
|
-
resolveKid,
|
|
151
|
-
context: ctx,
|
|
152
|
-
cache,
|
|
153
|
-
})
|
|
154
|
-
|
|
155
|
-
expect(validVerdict.decision).toBe('render')
|
|
156
|
-
expect(tamperedVerdict).toEqual({ decision: 'suppress', reason: 'invalid_signature' })
|
|
157
|
-
})
|
|
158
|
-
})
|
|
159
|
-
|
|
160
|
-
describe('getVerifiedEnvelope — Delivery API source', () => {
|
|
161
|
-
it('fetches from GET /api/cert/v1/delivery/{platform}/{externalId} and verifies', async () => {
|
|
162
|
-
const { envelope, resolveKid } = makeSignedEnvelope()
|
|
163
|
-
const fetchImpl = vi.fn(async () => new Response(JSON.stringify(envelope), { status: 200 }))
|
|
164
|
-
const verdict = await getVerifiedEnvelope({
|
|
165
|
-
source: {
|
|
166
|
-
kind: 'delivery_api',
|
|
167
|
-
baseUrl: 'https://portal.certrev.com',
|
|
168
|
-
platform: 'shopify',
|
|
169
|
-
externalId: 'gid://shopify/Article/123456789',
|
|
170
|
-
},
|
|
171
|
-
resolveKid,
|
|
172
|
-
context: { ...RENDER_CTX, now: new Date('2026-06-22T00:00:00Z') },
|
|
173
|
-
fetchImpl: fetchImpl as unknown as typeof fetch,
|
|
174
|
-
cache: new TtlCache(),
|
|
175
|
-
})
|
|
176
|
-
expect(verdict.decision).toBe('render')
|
|
177
|
-
expect(fetchImpl).toHaveBeenCalledOnce()
|
|
178
|
-
const calledUrl = (fetchImpl.mock.calls[0] as unknown[])[0] as string
|
|
179
|
-
expect(calledUrl).toBe(
|
|
180
|
-
'https://portal.certrev.com/api/cert/v1/delivery/shopify/gid%3A%2F%2Fshopify%2FArticle%2F123456789',
|
|
181
|
-
)
|
|
182
|
-
})
|
|
183
|
-
|
|
184
|
-
it('a 404 / 410 from the Delivery API fails closed to suppress', async () => {
|
|
185
|
-
const { resolveKid } = makeSignedEnvelope()
|
|
186
|
-
const fetchImpl = vi.fn(async () => new Response('', { status: 410 }))
|
|
187
|
-
const verdict = await getVerifiedEnvelope({
|
|
188
|
-
source: { kind: 'delivery_api', baseUrl: 'https://portal.certrev.com', platform: 'shopify', externalId: 'x' },
|
|
189
|
-
resolveKid,
|
|
190
|
-
context: RENDER_CTX,
|
|
191
|
-
fetchImpl: fetchImpl as unknown as typeof fetch,
|
|
192
|
-
cache: new TtlCache(),
|
|
193
|
-
})
|
|
194
|
-
expect(verdict.decision).toBe('suppress')
|
|
195
|
-
})
|
|
196
|
-
|
|
197
|
-
it('SINGLE-FLIGHTS concurrent SSR renders — N renders → ONE fetch (thundering-herd guard)', async () => {
|
|
198
|
-
const { envelope, resolveKid } = makeSignedEnvelope()
|
|
199
|
-
let calls = 0
|
|
200
|
-
const fetchImpl = vi.fn(async () => {
|
|
201
|
-
calls++
|
|
202
|
-
await new Promise((r) => setTimeout(r, 10))
|
|
203
|
-
return new Response(JSON.stringify(envelope), { status: 200 })
|
|
204
|
-
})
|
|
205
|
-
const cache = new TtlCache<never>() as unknown as TtlCache<import('../contract/kernel.js').CertVerdict>
|
|
206
|
-
const opts = {
|
|
207
|
-
source: {
|
|
208
|
-
kind: 'delivery_api' as const,
|
|
209
|
-
baseUrl: 'https://portal.certrev.com',
|
|
210
|
-
platform: 'shopify',
|
|
211
|
-
externalId: 'gid://shopify/Article/123456789',
|
|
212
|
-
},
|
|
213
|
-
resolveKid,
|
|
214
|
-
context: { ...RENDER_CTX, now: new Date('2026-06-22T00:00:00Z') },
|
|
215
|
-
fetchImpl: fetchImpl as unknown as typeof fetch,
|
|
216
|
-
cache,
|
|
217
|
-
}
|
|
218
|
-
const verdicts = await Promise.all([
|
|
219
|
-
getVerifiedEnvelope(opts),
|
|
220
|
-
getVerifiedEnvelope(opts),
|
|
221
|
-
getVerifiedEnvelope(opts),
|
|
222
|
-
getVerifiedEnvelope(opts),
|
|
223
|
-
getVerifiedEnvelope(opts),
|
|
224
|
-
])
|
|
225
|
-
expect(verdicts.every((v) => v.decision === 'render')).toBe(true)
|
|
226
|
-
expect(calls).toBe(1)
|
|
227
|
-
})
|
|
228
|
-
})
|
|
229
|
-
|
|
230
|
-
describe('staticKidResolver', () => {
|
|
231
|
-
it('resolves a configured kid and returns null for an unknown one', async () => {
|
|
232
|
-
const { envelope, publicKey, kid } = makeSignedEnvelope()
|
|
233
|
-
const pem = publicKey.export({ format: 'pem', type: 'spki' }).toString()
|
|
234
|
-
const resolver = staticKidResolver({ [kid]: pem })
|
|
235
|
-
const verdict = await verifyEnvelope(envelope, resolver, { ...RENDER_CTX, now: new Date('2026-06-22T00:00:00Z') })
|
|
236
|
-
expect(verdict.decision).toBe('render')
|
|
237
|
-
expect(await resolver('nope')).toBeNull()
|
|
238
|
-
})
|
|
239
|
-
})
|
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
/**
|
|
3
|
-
* Web Component + string-renderer tests.
|
|
4
|
-
*
|
|
5
|
-
* `renderBadgeHtml` is the framework-agnostic markup source shared by <CertBadge> (it
|
|
6
|
-
* mirrors the JSX) and the <certrev-badge> custom element. We assert it produces the same
|
|
7
|
-
* crawlable structure, hand-escapes every interpolated field, and drops unsafe URLs — then
|
|
8
|
-
* verify the custom element registers, honors a server-rendered light-DOM child (SSR mode),
|
|
9
|
-
* and fails closed in client mode without a configured key resolver.
|
|
10
|
-
*
|
|
11
|
-
* jsdom is opted into per-file (the rest of the suite runs in node) for `HTMLElement` +
|
|
12
|
-
* `customElements`.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { describe, expect, it } from 'vitest'
|
|
16
|
-
import { makeMockPayload } from '../contract/fixtures.js'
|
|
17
|
-
import {
|
|
18
|
-
CERTREV_BADGE_TAG,
|
|
19
|
-
CertRevBadgeElement,
|
|
20
|
-
defineCertRevBadge,
|
|
21
|
-
setCertRevKidResolver,
|
|
22
|
-
} from '../webcomponent/certrev-badge.js'
|
|
23
|
-
import { renderBadgeHtml } from '../webcomponent/render-badge-html.js'
|
|
24
|
-
|
|
25
|
-
const payload = makeMockPayload()
|
|
26
|
-
|
|
27
|
-
describe('renderBadgeHtml (the shared, framework-agnostic markup)', () => {
|
|
28
|
-
it('renders the same crawlable structure as <CertBadge>: section, expert, credentials, verify link', () => {
|
|
29
|
-
const html = renderBadgeHtml(payload)
|
|
30
|
-
expect(html).toMatch(/^<section class="certrev-badge certrev-badge--full"/)
|
|
31
|
-
expect(html).toContain('Dr. Jane Doe')
|
|
32
|
-
expect(html).toContain('MD, FAAD')
|
|
33
|
-
expect(html).toContain('Verify on CertREV')
|
|
34
|
-
expect(html).toContain('data-certrev-cert-id="cert_fixture_001"')
|
|
35
|
-
expect(html).toContain('aria-label="Content reviewed by Dr. Jane Doe, MD, FAAD"')
|
|
36
|
-
})
|
|
37
|
-
|
|
38
|
-
it('HAND-ESCAPES a hostile display name (no React here — the string path must escape itself)', () => {
|
|
39
|
-
const p = makeMockPayload({
|
|
40
|
-
content: {
|
|
41
|
-
...payload.content,
|
|
42
|
-
expert: { ...payload.content.expert, displayName: '<img src=x onerror=alert(1)>' },
|
|
43
|
-
memo: null,
|
|
44
|
-
},
|
|
45
|
-
})
|
|
46
|
-
const html = renderBadgeHtml(p)
|
|
47
|
-
expect(html).not.toContain('<img src=x')
|
|
48
|
-
expect(html).toContain('<img src=x onerror=alert(1)>')
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
it('DROPS a javascript: verify URL (safeHttpUrl) — no verify anchor emitted', () => {
|
|
52
|
-
const p = makeMockPayload({ content: { ...payload.content, verifyUrl: 'javascript:alert(1)' } })
|
|
53
|
-
const html = renderBadgeHtml(p)
|
|
54
|
-
expect(html).not.toContain('javascript:')
|
|
55
|
-
expect(html).not.toContain('__verify')
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
it('rejects an unsafe accentColor and falls back to the default token', () => {
|
|
59
|
-
const p = makeMockPayload({
|
|
60
|
-
content: { ...payload.content, display: { ...payload.content.display, accentColor: 'url(evil)' } },
|
|
61
|
-
})
|
|
62
|
-
const html = renderBadgeHtml(p)
|
|
63
|
-
expect(html).not.toContain('url(evil)')
|
|
64
|
-
expect(html).toContain('--certrev-accent:#0f766e')
|
|
65
|
-
})
|
|
66
|
-
|
|
67
|
-
it('compact style omits the photo, memo, and dates', () => {
|
|
68
|
-
const html = renderBadgeHtml(payload, { badgeStyle: 'compact' })
|
|
69
|
-
expect(html).toContain('certrev-badge--compact')
|
|
70
|
-
expect(html).not.toContain('__photo')
|
|
71
|
-
expect(html).not.toContain('__memo')
|
|
72
|
-
expect(html).not.toContain('__dates')
|
|
73
|
-
})
|
|
74
|
-
|
|
75
|
-
it('carries the always-on compliance baseline (cue + verbatim scope line + strings-version) on every face', () => {
|
|
76
|
-
const html = renderBadgeHtml(payload)
|
|
77
|
-
expect(html).toContain('data-certrev-strings-version="2026-07"')
|
|
78
|
-
expect(html).toContain(
|
|
79
|
-
'Independent editorial review of this article’s text and sources. Not a product endorsement, nor medical advice.',
|
|
80
|
-
)
|
|
81
|
-
expect(html).toContain('data-ftc-line')
|
|
82
|
-
expect(html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ')).toContain('Compensated expert')
|
|
83
|
-
})
|
|
84
|
-
})
|
|
85
|
-
|
|
86
|
-
describe('<certrev-badge> custom element', () => {
|
|
87
|
-
it('registers idempotently under its tag', () => {
|
|
88
|
-
defineCertRevBadge()
|
|
89
|
-
defineCertRevBadge() // second call is a no-op, must not throw
|
|
90
|
-
expect(customElements.get(CERTREV_BADGE_TAG)).toBe(CertRevBadgeElement)
|
|
91
|
-
})
|
|
92
|
-
|
|
93
|
-
it('SSR mode: leaves a server-rendered light-DOM badge child untouched on connect', () => {
|
|
94
|
-
defineCertRevBadge()
|
|
95
|
-
const host = document.createElement(CERTREV_BADGE_TAG)
|
|
96
|
-
host.innerHTML = renderBadgeHtml(payload) // server already rendered the badge inside
|
|
97
|
-
document.body.appendChild(host)
|
|
98
|
-
// connectedCallback saw an existing .certrev-badge child → must not wipe/replace it.
|
|
99
|
-
expect(host.querySelector('.certrev-badge')).not.toBeNull()
|
|
100
|
-
expect(host.innerHTML).toContain('Dr. Jane Doe')
|
|
101
|
-
host.remove()
|
|
102
|
-
})
|
|
103
|
-
|
|
104
|
-
it('client mode FAIL-CLOSED: with no key resolver configured it renders nothing', () => {
|
|
105
|
-
setCertRevKidResolver(null as never) // explicitly unconfigured
|
|
106
|
-
defineCertRevBadge()
|
|
107
|
-
const host = document.createElement(CERTREV_BADGE_TAG)
|
|
108
|
-
host.setAttribute('delivery-api', 'https://portal.certrev.com')
|
|
109
|
-
host.setAttribute('platform', 'shopify')
|
|
110
|
-
host.setAttribute('external-id', 'gid://shopify/Article/1')
|
|
111
|
-
document.body.appendChild(host)
|
|
112
|
-
// No resolver → cannot verify → renders nothing (fail-closed). No badge child appears.
|
|
113
|
-
expect(host.querySelector('.certrev-badge')).toBeNull()
|
|
114
|
-
host.remove()
|
|
115
|
-
})
|
|
116
|
-
})
|