Files
supabase/apps/studio/lib/session-replay.test.ts
T
Sean Oliver d439ba57f4 feat(studio): mask HTML attributes in session replay (#48818)
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Hardening ahead of any decision to enable session replay, plus a
dependency bump. Follow-up to #48515.

### What's inside

- ~50 lines of logic: the callback, the `url()` pattern, and the theme
and SVG-reference gates
([session-replay.ts](https://github.com/supabase/supabase/pull/48818/changes#diff-b7e4f10387ee7a116dd1673f70a55c0ba066687ff2d228bc2320eba75a349fac))
- ~170 lines of allowlist, one attribute name per line, skimmable ([same
file](https://github.com/supabase/supabase/pull/48818/changes#diff-b7e4f10387ee7a116dd1673f70a55c0ba066687ff2d228bc2320eba75a349fac))
- ~150 lines of comments saying why each group is allowlisted, since a
wrong entry is a privacy or a fidelity bug ([same
file](https://github.com/supabase/supabase/pull/48818/changes#diff-b7e4f10387ee7a116dd1673f70a55c0ba066687ff2d228bc2320eba75a349fac))
- ~430 lines of tests, one case per policy decision
([session-replay.test.ts](https://github.com/supabase/supabase/pull/48818/changes#diff-f9feb872ad0136cf87c7e9fb2af72eb3f4019464c06f0b7dd050ffb85373ccb8))
- 1 line of dependency bump, plus its lockfile
([package.json](https://github.com/supabase/supabase/pull/48818/changes#diff-50d7c39a9430d37971aa76858165ab4f7921c4cc4340b28e9b673ce6982e63cf))

## What is the current behavior?

Session replay is disabled in every environment, and no recordings
exist. This is about what a recording *would* contain if it were ever
switched on.

Attributes are the one channel replay masking cannot reach. `maskTextFn`
only sees DOM text nodes, so a component interpolating customer data
into a `placeholder`, `title` or `aria-label` would be captured
verbatim. Before `posthog-js` 1.413.0 there was no hook for it at all,
and the only mitigation was blocking the element, which drops it from
the capture entirely.

Two places in Studio where that would apply:

- `CreateOrUpdateCustomProviderSheet.tsx:506-507` interpolates the
project's API host into both `value` and `placeholder`. The `value` is
masked. The `placeholder` is not.
- `FileExplorerHeader.tsx:185` renders `Search in
${currentFolderName}...`, a customer storage folder name.

The list is not complete. Any component echoing context into a tooltip
reproduces it, and the author has no reason to be thinking about replay.

Linear [GROWTH-1094](https://linear.app/supabase/issue/GROWTH-1094).
Blocks [GROWTH-1073](https://linear.app/supabase/issue/GROWTH-1073).

## What is the new behavior?

`maskAttributeFn` with a default-deny policy: an allowlist of the
attributes replay needs to render, everything else masked.

### Policy edge cases

- **rrweb's `rr_*` layout attributes have to be allowlisted
explicitly.** posthog-js only applies its own exemption for those when
`maskAllElementAttributes` does the masking. A callback does not get the
exemption.
- **HTML `id` is masked. SVG `id` passes.** `AreaChart.tsx:119` emits
`<linearGradient id="colorUv">` and references it as
`fill="url(#colorUv)"`, so masking it breaks the gradient. But Studio
also binds customer-named values to `id` (`bucket.id` is a storage
bucket name). Split on `element.namespaceURI`.
- **SVG reference attributes pass only fragment-only targets.** recharts
clips every series with `clip-path="url(#clipPath-<id>)"`, so
`clip-path`, `mask`, `filter`, `marker-*`, `fill` and `stroke` have to
survive. They accept external URLs too, so the policy checks the target
rather than allowlisting the attribute name.
- **The `url()` pattern consumes escaped delimiters and ignores case.**
A target containing a quote serializes as `\"` and one containing a
bracket as `\)`, so a naive `[^")]*` stops at the backslash and leaves
the tail of the URL recorded. `URL(...)` is the same function as
`url(...)`. A token the pattern cannot parse falls through to a masking
fallback rather than passing.
- **`url()` targets inside `style` are masked, keeping the
declarations.** The feedback widget puts `toPng(document.body)`, a
base64 PNG of the whole dashboard, into a `background-image`, and the
storage preview panes put signed object URLs there. No other masking
path covers those, because they are not text nodes, a canvas, a network
request or an `img src`.

The config also pins `maskAllElementAttributes: false`. Left unset it
resolves from the PostHog UI, and `true` discards `maskAttributeFn`
entirely.

The `posthog-js` floor rises to `^1.416.1`, the first version carrying
both attribute masking and the "coarse option wins" precedence.

This does not enable recording anywhere.

## Additional context

### Verification

Ran on the studio-staging preview against a live session: 817 seconds,
190 clicks, 82 keypresses. Staging has no server-side masking config, so
everything masked came from this code.

| Check | Result |
|---|---|
| Storage folder search placeholder | Asterisked. Pre-fix it read
`Search in <folder>...` |
| Custom auth provider sheet | Fully masked, including the callback URL
field |
| Canary folder name in event properties | 0 hits, with 51 events in the
session as the control |
| Console capture | `console_log_count: 0` despite the project having
`capture_console_log_opt_in: true` |
| Telemetry regression | None: `$pageview` x34, `$pageleave` x5,
`$groupidentify` x4, `$identify` x1 |

Recording was scoped to that one preview by an origin restriction plus a
URL trigger. Both were reverted afterwards along with the project
toggle.

The policy has 175 unit tests. Separately, the config was bundled with
esbuild and applied to a DOM reproducing Studio's serialized output (the
AreaChart gradient, a recharts `clip-path`, a lucide icon, an inline
`background-image`), and the chart, gradient fill and icon come out
pixel-identical.

### Known fidelity costs

- `img src` is masked, so images don't render in replay. Storage object
URLs are signed customer content.
- `ProviderIcon` renders its mark as `maskImage: url(<src>)` and
`normalizeIconPath` accepts absolute URLs, so provider icons don't
render either.

### Out of scope

rrweb records `<style>` element text without calling either masking
function, because its text-node serializer skips masking when the parent
is `STYLE`. This PR does not reach that channel. Fixed separately in
#50270 / [GROWTH-1229](https://linear.app/supabase/issue/GROWTH-1229).

`captureJsonLd` also defaults on as of PostHog's 2026-08-30 defaults,
which is a capture channel masking doesn't reach. Studio renders no
`ld+json`, so it's inert there, and pinning it off was left out to keep
this PR to its scope.

### The allowlist is the weak part

The policy is default-deny over attribute *names*, so its surface is
every attribute any shipped library emits, and that set grows with each
dependency. A miss is also invisible to these tests, which assert what
the function returns rather than whether some selector elsewhere still
matches. Both failure directions are reachable that way: an attribute
carrying customer data, and an attribute a stylesheet needs.

[GROWTH-1232](https://linear.app/supabase/issue/GROWTH-1232) tracks the
mechanism change: scope by namespace instead of by name, since 50 of the
159 entries exist only to serve SVG rendering, plus a conformance test
that derives the expected set from the codebase so a new dependency
fails CI rather than degrading a replay. Deliberately not done here,
since rewriting the mechanism of a privacy control buys maintainability
rather than correctness.
2026-09-14 09:48:58 -07:00

591 lines
21 KiB
TypeScript

import { buildSessionRecordingConfig, type CapturedNetworkRequest } from 'common'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
maskReplayAttribute,
maskReplayCssUrls,
maskReplayNetworkRequest,
maskReplayText,
SESSION_REPLAY_CONFIG,
} from './session-replay'
const elementWith = (attributes: Record<string, string>) => {
const element = document.createElement('span')
Object.entries(attributes).forEach(([key, value]) => element.setAttribute(key, value))
return element
}
const networkRequest = (name: string): CapturedNetworkRequest => ({
name,
entryType: 'resource',
startTime: 0,
duration: 0,
})
describe('maskReplayText', () => {
it('masks text by default', () => {
expect(maskReplayText('postgresql://postgres:hunter2@db.abc.supabase.co:5432')).toBe(
'*'.repeat('postgresql://postgres:hunter2@db.abc.supabase.co:5432'.length)
)
})
it('masks text when no element is given', () => {
expect(maskReplayText('secret', undefined)).toBe('******')
})
it('masks based on trimmed length so whitespace is not leaked', () => {
expect(maskReplayText(' abc ')).toBe('***')
})
it('captures text opted in with data-ph-capture', () => {
const element = elementWith({ 'data-ph-capture': 'true' })
expect(maskReplayText('Table editor', element)).toBe('Table editor')
})
it('masks text when data-ph-capture is not exactly "true"', () => {
expect(maskReplayText('secret', elementWith({ 'data-ph-capture': 'false' }))).toBe('******')
expect(maskReplayText('secret', elementWith({ 'data-ph-capture': '' }))).toBe('******')
expect(maskReplayText('secret', elementWith({ 'data-ph-capture': 'TRUE' }))).toBe('******')
})
it('masks text on elements carrying unrelated data attributes', () => {
expect(maskReplayText('secret', elementWith({ 'data-capture': 'true' }))).toBe('******')
})
})
describe('maskReplayAttribute', () => {
it.each([
['placeholder', 'Search in customer-invoices...'],
['title', 'acme-production'],
['aria-label', 'Delete project acme'],
['aria-describedby-text', 'acme'],
['alt', 'Avatar for jane@acme.com'],
['value', 'postgresql://postgres:hunter2@db.abc.supabase.co:5432'],
['label', 'acme-production'],
['name', 'acme'],
['data-project-ref', 'abcdefghijklmnop'],
['data-testid', 'project-acme'],
])('masks %o, which can carry interpolated customer data', (name, value) => {
expect(maskReplayAttribute(name, value)).toBe('*')
})
it.each(['href', 'src', 'srcset', 'action', 'poster', 'formaction', 'data-href'])(
'masks %o, since project and storage paths ride in URLs',
(name) => {
const url = 'https://abc.supabase.co/storage/v1/object/invoices/q4.pdf'
expect(maskReplayAttribute(name, url, elementWith({}))).toBe('*')
}
)
it.each([
['class', 'flex items-center gap-2 text-foreground-light'],
['type', 'checkbox'],
['colspan', '3'],
['dir', 'ltr'],
['role', 'menuitem'],
['tabindex', '-1'],
['aria-hidden', 'true'],
['aria-expanded', 'false'],
['data-state', 'open'],
['data-orientation', 'vertical'],
])('leaves %o alone, since replay needs it to render', (name, value) => {
expect(maskReplayAttribute(name, value)).toBe(value)
})
it.each([
['d', 'M4 6h16M4 12h16M4 18h16'],
['viewBox', '0 0 24 24'],
['stroke-width', '2'],
['fill', 'none'],
['xmlns', 'http://www.w3.org/2000/svg'],
])('leaves SVG geometry attribute %o alone, so icons still render', (name, value) => {
expect(maskReplayAttribute(name, value)).toBe(value)
})
it.each([
['fill', 'url(#colorUv)'],
['clip-path', 'url(#clipPath-recharts-1)'],
['mask', 'url(#mask-1)'],
['filter', 'url(#shadow-1)'],
['marker-end', 'url(#arrow)'],
])('leaves SVG reference %o intact, since masking it drops the effect', (name, value) => {
expect(maskReplayAttribute(name, value)).toBe(value)
})
it.each([
['rr_width', '1280'],
['rr_scrollTop', '240'],
['rr_open_mode', 'closed'],
['_cssText', '.a{color:red}'],
])(
'leaves rrweb-generated attribute %o alone, which posthog-js does not exempt for callbacks',
(name, value) => {
expect(maskReplayAttribute(name, value)).toBe(value)
}
)
it('masks attributes it has never seen, so a new leak is masked rather than recorded', () => {
expect(maskReplayAttribute('data-some-future-attribute', 'acme')).toBe('*')
expect(maskReplayAttribute('tooltip', 'acme')).toBe('*')
})
it('matches attribute names case-insensitively', () => {
expect(maskReplayAttribute('CLASS', 'flex')).toBe('flex')
expect(maskReplayAttribute('ARIA-LABEL', 'acme')).toBe('*')
})
it('keeps stylesheet URLs, which replay needs for sheets rrweb could not inline', () => {
const href = 'https://supabase.com/dashboard/_next/static/css/main.css'
const link = document.createElement('link')
link.setAttribute('rel', 'stylesheet')
expect(maskReplayAttribute('href', href, link)).toBe(href)
})
it.each(['preload', 'icon', 'preconnect', ''])(
'masks a <link rel=%o> href, which is not a stylesheet',
(rel) => {
const link = document.createElement('link')
link.setAttribute('rel', rel)
expect(
maskReplayAttribute('href', 'https://x.test/customer-avatar.png?token=secret', link)
).toBe('*')
}
)
it('masks a <link> href with no rel at all', () => {
const link = document.createElement('link')
expect(maskReplayAttribute('href', 'https://x.test/a.png?token=secret', link)).toBe('*')
})
it('keeps a stylesheet href in an XHTML document, where tagName is lowercased', () => {
const xhtml = document.implementation.createDocument(
'http://www.w3.org/1999/xhtml',
'html',
null
)
const link = xhtml.createElementNS('http://www.w3.org/1999/xhtml', 'link')
link.setAttribute('rel', 'stylesheet')
expect(link.tagName).toBe('link')
expect(maskReplayAttribute('href', '/app.css', link)).toBe('/app.css')
})
it('masks anchor URLs even though stylesheet URLs are kept', () => {
const href = 'https://supabase.com/dashboard/project/abcdefghijklmnop/editor'
const anchor = document.createElement('a')
expect(maskReplayAttribute('href', href, anchor)).toBe('*')
})
it('masks when no element is given', () => {
const href = 'https://supabase.com/dashboard/project/abc'
expect(maskReplayAttribute('href', href, undefined)).toBe('*')
})
it('masks HTML ids, which Studio binds to storage bucket names', () => {
expect(maskReplayAttribute('id', 'customer-invoices', elementWith({}))).toBe('*')
expect(maskReplayAttribute('for', 'customer-invoices')).toBe('*')
})
it('keeps SVG ids, which fill="url(#id)" references for gradients and clip paths', () => {
const gradient = document.createElementNS('http://www.w3.org/2000/svg', 'linearGradient')
expect(maskReplayAttribute('id', 'recharts-gradient-1', gradient)).toBe('recharts-gradient-1')
})
it('masks the interpolated placeholders found in Studio', () => {
const storageSearch = 'Search in customer-invoices...'
expect(maskReplayAttribute('placeholder', storageSearch)).toBe('*')
const authCallback = 'https://abcdefghijklmnop.supabase.co/auth/v1/callback'
expect(maskReplayAttribute('placeholder', authCallback)).toBe('*')
})
it('keeps CSS that carries no url()', () => {
expect(maskReplayAttribute('style', 'transform: translateX(4px)')).toBe(
'transform: translateX(4px)'
)
})
it.each([
'background-image: url("data:image/png;base64,iVBORw0KGgoAAAA")',
"background-image: url('https://abc.supabase.co/storage/v1/object/sign/invoices/q4.pdf')",
'background-image: url(https://abc.supabase.co/avatars/jane.png)',
])('masks the url() target in %o', (css) => {
const masked = maskReplayAttribute('style', css)
expect(masked).toBe('background-image: url(*)')
})
it('leaves url() inside inlined stylesheets alone, which are our own CSS', () => {
const sheet = '.a{background:url("https://x.com/a.png")}'
expect(maskReplayAttribute('_cssText', sheet)).toBe(sheet)
})
it('keeps the surrounding declaration when masking a url()', () => {
expect(
maskReplayAttribute('style', 'background: url("https://x.com/a.png") no-repeat center')
).toBe('background: url(*) no-repeat center')
})
it('masks every url() in a value, not just the first', () => {
expect(maskReplayCssUrls('a{background:url(x.png)}b{background:url(y.png)}')).toBe(
'a{background:url(*)}b{background:url(*)}'
)
})
it('masks a url() target containing a bracket, legal in a storage object name', () => {
expect(maskReplayCssUrls('background: url("invoices/q4 (final).pdf")')).toBe(
'background: url(*)'
)
})
it('leaves gradients alone, since they carry no URL', () => {
const gradient = 'background: linear-gradient(to right, #fff 0%, #000 100%)'
expect(maskReplayCssUrls(gradient)).toBe(gradient)
})
it('masks to a constant, so value length is not leaked', () => {
expect(maskReplayAttribute('title', 'ab')).toBe('*')
expect(maskReplayAttribute('title', 'abcdefgh')).toBe('*')
expect(maskReplayAttribute('title', 'a'.repeat(500))).toBe('*')
})
it('masks a url() target whose name contains an escaped quote', () => {
expect(maskReplayCssUrls('background: url("invoices/(acme) \\"q4\\".pdf")')).toBe(
'background: url(*)'
)
})
it('masks an unquoted url() target containing an escaped bracket', () => {
expect(maskReplayCssUrls('background: url(invoices/q4\\)secret.pdf)')).toBe(
'background: url(*)'
)
})
it('masks an unterminated url() token rather than letting it through', () => {
expect(maskReplayCssUrls('background: url(trailing\\')).not.toContain('trailing')
expect(maskReplayCssUrls('background: url(secret\\)')).not.toContain('secret')
})
it.each([
'background: URL("https://x.test/secret.png")',
'background: Url(https://x.test/secret.png)',
])('masks url() regardless of case in %o', (css) => {
expect(maskReplayCssUrls(css)).toContain('url(*)')
expect(maskReplayCssUrls(css)).not.toContain('secret')
})
it.each([
['fill', 'URL(https://x.test/secret.svg#paint)'],
['clip-path', 'Url(https://x.test/secret.svg#c)'],
])('masks an uppercase URL() in SVG reference %o', (name, value) => {
expect(maskReplayAttribute(name, value)).toBe('*')
})
it.each([
['fill', 'URL(#colorUv)'],
['clip-path', 'Url(#clipPath-1)'],
])('keeps an uppercase fragment-only URL() in %o', (name, value) => {
expect(maskReplayAttribute(name, value)).toBe(value)
})
it('keeps a fragment-only url(), which names a node in the recording', () => {
expect(maskReplayAttribute('style', 'clip-path: url(#clip-1)')).toBe('clip-path: url(#clip-1)')
expect(maskReplayCssUrls('mask: url("#m")')).toBe('mask: url("#m")')
})
it('masks an external url() even when a fragment is appended', () => {
expect(maskReplayCssUrls('fill: url(https://x.test/customer.svg#paint)')).toBe('fill: url(*)')
})
it.each([
['fill', 'url(https://x.test/customer-secret.svg#paint)'],
['stroke', 'url("https://x.test/secret.svg#s")'],
['clip-path', 'url(https://x.test/secret.svg#c)'],
['mask', 'url(https://x.test/secret.svg#m)'],
['filter', 'url(https://x.test/secret.svg#f)'],
['marker-end', 'url(https://x.test/secret.svg#a)'],
])('masks SVG reference %o pointing at an external URL', (name, value) => {
expect(maskReplayAttribute(name, value)).toBe('*')
})
it.each([
['fill', '#24b47e'],
['fill', 'none'],
['stroke', 'currentColor'],
])('keeps plain SVG presentation value %o %o', (name, value) => {
expect(maskReplayAttribute(name, value)).toBe(value)
})
it.each(['dark', 'light', 'classic-dark', 'system'])(
'keeps data-theme=%o, which monaco.css and grid.css select on',
(theme) => {
expect(maskReplayAttribute('data-theme', theme)).toBe(theme)
}
)
it('masks a data-theme value outside the known themes', () => {
expect(maskReplayAttribute('data-theme', 'acme-production')).toBe('*')
})
it.each([
['data-collapsible', 'icon'],
['data-variant', 'destructive'],
['data-sidebar', 'menu-button'],
['data-active', 'true'],
['data-size', 'sm'],
['data-selected', 'true'],
['data-expanded', 'false'],
['data-motion', 'from-start'],
['data-invalid', 'true'],
['data-front', 'true'],
['data-invisible', 'true'],
['data-separator', 'active'],
['data-vaul-drawer-direction', 'right'],
['aria-invalid', 'true'],
['aria-pressed', 'false'],
])('keeps %o, which a Tailwind variant in packages/ui selects on', (name, value) => {
expect(maskReplayAttribute(name, value)).toBe(value)
})
it.each([
['rows', '8'],
['cols', '40'],
])('keeps textarea %o, since masking it collapses the box in replay', (name, value) => {
expect(maskReplayAttribute(name, value)).toBe(value)
})
it('masks data-value, which cmdk fills with the item value', () => {
expect(maskReplayAttribute('data-value', 'customer_email')).toBe('*')
})
it('keeps data-chart, which the chart stylesheet selector has to match', () => {
expect(maskReplayAttribute('data-chart', 'chart-r1a')).toBe('chart-r1a')
})
it.each([
['data-index', '0'],
['data-band', '3'],
])('keeps %o, which a shipped stylesheet selects on by value', (name, value) => {
expect(maskReplayAttribute(name, value)).toBe(value)
})
it.each([
['data-sonner-toast', ''],
['data-mounted', 'true'],
['data-visible', 'false'],
['data-x-position', 'right'],
['data-y-position', 'bottom'],
['data-type', 'error'],
['data-styled', 'true'],
['data-removed', 'false'],
])('keeps sonner marker %o, without which every toast is invisible in replay', (name, value) => {
expect(maskReplayAttribute(name, value)).toBe(value)
})
it.each([
['data-vaul-overlay', ''],
['data-vaul-snap-points', 'false'],
['data-vaul-drawer', ''],
])('keeps vaul marker %o', (name, value) => {
expect(maskReplayAttribute(name, value)).toBe(value)
})
it.each([
['data-radix-portal', ''],
['data-radix-popper-content-wrapper', ''],
['data-footnote-ref', ''],
])('keeps %o, which Studio stylesheets and Radix positioning select on', (name, value) => {
expect(maskReplayAttribute(name, value)).toBe(value)
})
it.each([
['text-anchor', 'middle'],
['dominant-baseline', 'central'],
['dy', '0.71em'],
['dx', '-4'],
['gradientUnits', 'userSpaceOnUse'],
['gradientTransform', 'translate(263 73.5) rotate(-143.669)'],
// Names as they appear in the DOM, which is what rrweb reads. `maskUnits` and
// `gradientUnits` are camelCase in the SVG spec; `shape-rendering` is hyphenated,
// and React's `shapeRendering` prop sets that name.
['maskUnits', 'userSpaceOnUse'],
['shape-rendering', 'geometricPrecision'],
])('keeps SVG placement attribute %o, so geometry stays put', (name, value) => {
expect(maskReplayAttribute(name, value)).toBe(value)
})
it('masks the React prop spelling, since rrweb never sees it', () => {
// A guard against allowlisting a camelCase prop name that no DOM attribute matches.
expect(maskReplayAttribute('shapeRendering', 'geometricPrecision')).toBe('*')
})
it.each([
['patternUnits', 'userSpaceOnUse'],
['patternTransform', 'translate(-8,-8)'],
['markerUnits', 'strokeWidth'],
['orient', 'auto-start-reverse'],
['refX', '5'],
['font-size', '10'],
])('keeps %o, which a rendering library emits and depends on', (name, value) => {
expect(maskReplayAttribute(name, value)).toBe(value)
})
it.each([
['draggable', 'true'],
['align', 'right'],
['data-field', 'instance-details'],
])('keeps %o, selected on by value in Studio stylesheets', (name, value) => {
expect(maskReplayAttribute(name, value)).toBe(value)
})
it('keeps stroke-dashoffset, which draws the fill level of a usage ring', () => {
// Masked it is invalid, so the browser falls back to 0 and the ring replays full.
expect(maskReplayAttribute('stroke-dashoffset', 'calc(75.39822 - 18.8)')).toBe(
'calc(75.39822 - 18.8)'
)
expect(maskReplayAttribute('stroke-dasharray', '75.39822')).toBe('75.39822')
})
it('passes _cssText through, since it carries our own inlined stylesheets', () => {
const fontFace = "@font-face{src:url('../fonts/inter/InterVariable.woff2') format('woff2')}"
expect(maskReplayAttribute('_cssText', fontFace)).toBe(fontFace)
const checkbox =
'.rdg-cell [type=checkbox]:checked{background-image:url("data:image/svg+xml,%3Csvg%3E")}'
expect(maskReplayAttribute('_cssText', checkbox)).toBe(checkbox)
})
it('still masks url() in an inline style attribute', () => {
expect(maskReplayAttribute('style', 'background-image: url("https://x.test/secret.png")')).toBe(
'background-image: url(*)'
)
})
it('keeps the SVG namespace exemption to id only', () => {
const svgAnchor = document.createElementNS('http://www.w3.org/2000/svg', 'a')
expect(maskReplayAttribute('href', 'https://x.test/secret', svgAnchor)).toBe('*')
const svgImage = document.createElementNS('http://www.w3.org/2000/svg', 'image')
expect(maskReplayAttribute('href', 'https://x.test/secret.png', svgImage)).toBe('*')
})
})
describe('maskReplayNetworkRequest', () => {
it('strips query strings', () => {
expect(
maskReplayNetworkRequest(networkRequest('https://api.supabase.com/v1/x?token=abc')).name
).toBe('https://api.supabase.com/v1/x')
})
it('strips fragments, which carry GoTrue access tokens on auth callbacks', () => {
expect(
maskReplayNetworkRequest(networkRequest('https://supabase.com/dashboard#access_token=abc'))
.name
).toBe('https://supabase.com/dashboard')
})
it('strips from the first separator when both are present', () => {
expect(maskReplayNetworkRequest(networkRequest('https://x.com/a?b=1#c=2')).name).toBe(
'https://x.com/a'
)
expect(maskReplayNetworkRequest(networkRequest('https://x.com/a#c=2?b=1')).name).toBe(
'https://x.com/a'
)
})
it('leaves URLs without a query string or fragment alone', () => {
expect(maskReplayNetworkRequest(networkRequest('https://x.com/project/abc/editor')).name).toBe(
'https://x.com/project/abc/editor'
)
})
it('returns the request rather than dropping it, so timings are still captured', () => {
const request = networkRequest('https://x.com/a?b=1')
expect(maskReplayNetworkRequest(request)).toBe(request)
})
})
describe('SESSION_REPLAY_CONFIG', () => {
it('masks all text and inputs', () => {
expect(SESSION_REPLAY_CONFIG.maskTextSelector).toBe('*')
expect(SESSION_REPLAY_CONFIG.maskAllInputs).toBe(true)
expect(SESSION_REPLAY_CONFIG.maskTextFn).toBe(maskReplayText)
})
it('never records request or response payloads', () => {
expect(SESSION_REPLAY_CONFIG.recordHeaders).toBe(false)
expect(SESSION_REPLAY_CONFIG.recordBody).toBe(false)
})
it('never records canvas, which text masking cannot reach', () => {
expect(SESSION_REPLAY_CONFIG.captureCanvas).toEqual({ recordCanvas: false })
})
it('strips sensitive URL parts via maskReplayNetworkRequest', () => {
expect(SESSION_REPLAY_CONFIG.maskCapturedNetworkRequestFn).toBe(maskReplayNetworkRequest)
})
it('masks attributes, which maskTextFn cannot reach', () => {
expect(SESSION_REPLAY_CONFIG.maskAttributeFn).toBe(maskReplayAttribute)
})
it('pins maskAllElementAttributes off, since true would discard maskAttributeFn', () => {
expect(SESSION_REPLAY_CONFIG.maskAllElementAttributes).toBe(false)
})
})
describe('buildSessionRecordingConfig', () => {
it('disables recording when given no policy', () => {
const config = buildSessionRecordingConfig()
expect(config.disable_session_recording).toBe(true)
expect(config).not.toHaveProperty('session_recording')
})
it('disables recording when the policy is undefined', () => {
const config = buildSessionRecordingConfig(undefined)
expect(config.disable_session_recording).toBe(true)
expect(config).not.toHaveProperty('session_recording')
})
it('enables recording and forwards the policy when given one', () => {
const config = buildSessionRecordingConfig(SESSION_REPLAY_CONFIG)
expect(config.disable_session_recording).toBe(false)
expect(config.session_recording).toBe(SESSION_REPLAY_CONFIG)
})
it.each([undefined, SESSION_REPLAY_CONFIG])(
'never records console logs, which masking cannot reach (%#)',
(sessionReplay) => {
expect(buildSessionRecordingConfig(sessionReplay).enable_recording_console_log).toBe(false)
}
)
})
describe('IS_SESSION_REPLAY_ENABLED', () => {
beforeEach(() => {
vi.resetModules()
})
afterEach(() => {
vi.unstubAllEnvs()
})
it('is true only for the exact string "true"', async () => {
vi.stubEnv('NEXT_PUBLIC_POSTHOG_SESSION_REPLAY', 'true')
const { IS_SESSION_REPLAY_ENABLED } = await import('./session-replay')
expect(IS_SESSION_REPLAY_ENABLED).toBe(true)
})
it.each(['false', '', 'TRUE', '1'])('is false for %o', async (value) => {
vi.stubEnv('NEXT_PUBLIC_POSTHOG_SESSION_REPLAY', value)
const { IS_SESSION_REPLAY_ENABLED } = await import('./session-replay')
expect(IS_SESSION_REPLAY_ENABLED).toBe(false)
})
it('is false when unset', async () => {
vi.stubEnv('NEXT_PUBLIC_POSTHOG_SESSION_REPLAY', undefined)
const { IS_SESSION_REPLAY_ENABLED } = await import('./session-replay')
expect(IS_SESSION_REPLAY_ENABLED).toBe(false)
})
})