* fix(analytics): mask session replays by default, chrome-only unmask Invert PostHog session-replay masking from visible-by-default with pattern masking to deny-by-default: every input value is masked wholesale (rrweb maskAllInputs, no maskInputFn) and every text node is masked unless it sits under data-ph-unmask chrome or a table column header (th). Chrome tags live on the shared UI primitives (PageHeader, Label, Button except combobox triggers, TabsTrigger, Badge, Card/Dialog/Sheet titles, tooltips, help popovers, empty states, settings labels), and tagged chrome is still pattern-scrubbed for amounts and person-/organisationsnummer. data-ph-mask beats data-ph-unmask, so call sites that interpolate user data into chrome stay masked; a very-thorough audit swept every unmasked primitive and each found site got a call-site mask. Confirm-dialog wrappers and toasts stay masked centrally: their copy describes user objects by design. Untagged new UI over-masks instead of leaking. Privacy policy, RoPA and decision log updated in the same change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(analytics): tag detail-section chrome merged from main The register-detail primitives landed on main after the replay-masking audit ran: kickers and DefRow labels are static i18n chrome, values stay masked. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(analytics): close skeptic and review findings on replay masking Explicit data-ph tags now resolve before the th chrome fallback, so a th nested inside a data-ph-mask container masks correctly (regression test added). Seven missed text-leak sites get call-site masks: delete-invoice and credit-page invoice numbers, IB-correction voucher reference, TIC orgnr (served unnormalized, so the separator-based scrub cannot be relied on), articles search-term empty state, dimension segment labels, and activate-account buttons. The attribute channel is closed with rrweb's blockClass: inputs whose placeholder carries an effective user value (salary overrides, correction description, danger-zone confirms, credit confirm) get ph-no-capture, removing the element from recordings while the prefill UX stays intact; the pivot-th title attribute is dropped. Privacy-policy effective date bumped to 2026-08-17. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
121 lines
4.5 KiB
TypeScript
121 lines
4.5 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||
import { maskSensitiveText, replayMaskText } from '@/lib/analytics/replay-masking'
|
||
|
||
// Repo test convention. eventBus.clear() is deliberately absent: these are
|
||
// pure functions and importing the bus would only add module side effects.
|
||
beforeEach(() => {
|
||
vi.clearAllMocks()
|
||
})
|
||
|
||
/**
|
||
* Minimal stand-ins for the DOM elements rrweb hands to the masking
|
||
* functions (tests run in the node environment, no jsdom). `closest` is
|
||
* called once with the explicit-tag selector and, when that misses, once
|
||
* with 'th'; the fake answers each selector like a real DOM lookup would.
|
||
*/
|
||
function fakeElement(
|
||
opts: { tagged?: 'mask' | 'unmask' | 'both' | null; th?: boolean } = {}
|
||
): HTMLElement {
|
||
const attrs =
|
||
opts.tagged === 'mask'
|
||
? ['data-ph-mask']
|
||
: opts.tagged === 'unmask'
|
||
? ['data-ph-unmask']
|
||
: opts.tagged === 'both'
|
||
? ['data-ph-mask', 'data-ph-unmask']
|
||
: null
|
||
const tagged = attrs ? { hasAttribute: (name: string) => attrs.includes(name) } : null
|
||
const thAncestor = opts.th ? { hasAttribute: () => false } : null
|
||
return {
|
||
closest: (selector: string) => (selector.includes('data-ph') ? tagged : thAncestor),
|
||
} as unknown as HTMLElement
|
||
}
|
||
|
||
describe('maskSensitiveText', () => {
|
||
it('masks sv-SE formatted amounts, preserving length and whitespace', () => {
|
||
// First variant groups thousands with U+00A0 (what Intl sv-SE emits), the second with a regular space.
|
||
expect(maskSensitiveText('1 234,56 kr')).toBe('* ****** **')
|
||
expect(maskSensitiveText('1 234,56 kr')).toBe('* ****** **')
|
||
})
|
||
|
||
it('masks negative amounts with both hyphen and the Intl minus sign', () => {
|
||
expect(maskSensitiveText('-500 kr')).toBe('**** **')
|
||
expect(maskSensitiveText('−1 234 kr')).toBe('** *** **')
|
||
})
|
||
|
||
it('masks the amount inside surrounding text', () => {
|
||
expect(maskSensitiveText('Totalt 1 234 kr att betala')).toBe('Totalt * *** ** att betala')
|
||
expect(maskSensitiveText('999 kr/mån')).toBe('*** **/mån')
|
||
})
|
||
|
||
it('masks other currency markers', () => {
|
||
expect(maskSensitiveText('12,00 €')).toBe('***** *')
|
||
expect(maskSensitiveText('10 US$')).toBe('** ***')
|
||
expect(maskSensitiveText('1 000 SEK')).toBe('* *** ***')
|
||
})
|
||
|
||
it('masks person- and organisationsnummer', () => {
|
||
expect(maskSensitiveText('556677-8899')).toBe('***********')
|
||
expect(maskSensitiveText('19850101-1234')).toBe('*************')
|
||
expect(maskSensitiveText('850101+1234')).toBe('***********')
|
||
})
|
||
|
||
it('leaves non-amount, non-identity text untouched', () => {
|
||
for (const text of [
|
||
'2026-08-06',
|
||
'Verifikat A-217',
|
||
'070-123 45 67',
|
||
'5050-1055',
|
||
'namn@exempel.se',
|
||
'10 kronor',
|
||
'E-postadress',
|
||
'Konto 1930',
|
||
]) {
|
||
expect(maskSensitiveText(text)).toBe(text)
|
||
}
|
||
})
|
||
})
|
||
|
||
describe('replayMaskText', () => {
|
||
it('masks everything when the node has no chrome ancestor', () => {
|
||
expect(replayMaskText('Acme AB', fakeElement())).toBe('**** **')
|
||
expect(replayMaskText('Kaffe till kontoret', fakeElement())).toBe('***** **** ********')
|
||
expect(replayMaskText('Acme AB', undefined)).toBe('**** **')
|
||
})
|
||
|
||
it('masks everything when rrweb passes an element without closest (text node parents can be non-Element)', () => {
|
||
expect(replayMaskText('Acme AB', {} as unknown as HTMLElement)).toBe('**** **')
|
||
})
|
||
|
||
it('shows chrome text under data-ph-unmask', () => {
|
||
expect(replayMaskText('Bokför och godkänn', fakeElement({ tagged: 'unmask' }))).toBe(
|
||
'Bokför och godkänn'
|
||
)
|
||
})
|
||
|
||
it('shows table column headers (th) without a tag', () => {
|
||
expect(replayMaskText('Datum', fakeElement({ th: true }))).toBe('Datum')
|
||
})
|
||
|
||
it('lets an explicit data-ph-mask beat the th fallback (th inside a masked container, or masked th)', () => {
|
||
expect(replayMaskText('Acme AB', fakeElement({ tagged: 'mask', th: true }))).toBe('**** **')
|
||
})
|
||
|
||
it('pattern-scrubs amounts and identity numbers even inside chrome', () => {
|
||
expect(replayMaskText('Betala 1 234 kr nu', fakeElement({ tagged: 'unmask' }))).toBe(
|
||
'Betala * *** ** nu'
|
||
)
|
||
expect(replayMaskText('Ta bort 556677-8899', fakeElement({ tagged: 'unmask' }))).toBe(
|
||
'Ta bort ***********'
|
||
)
|
||
})
|
||
|
||
it('masks everything under data-ph-mask', () => {
|
||
expect(replayMaskText('Acme AB', fakeElement({ tagged: 'mask' }))).toBe('**** **')
|
||
})
|
||
|
||
it('lets mask win when both attributes land on the same element', () => {
|
||
expect(replayMaskText('Acme AB', fakeElement({ tagged: 'both' }))).toBe('**** **')
|
||
})
|
||
})
|