Files
accounted/lib/analytics/__tests__/replay-masking.test.ts
T
MattssonandClaude Fable 5 f8db38f989 fix(analytics): mask session replays by default, chrome-only unmask (#1639)
* 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>
2026-08-17 11:32:45 +02:00

121 lines
4.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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('**** **')
})
})