diff --git a/packages/integration-tests/suites/replay/privacyInput/init.js b/packages/integration-tests/suites/replay/privacyInput/init.js new file mode 100644 index 000000000000..a09c517b6a92 --- /dev/null +++ b/packages/integration-tests/suites/replay/privacyInput/init.js @@ -0,0 +1,19 @@ +import * as Sentry from '@sentry/browser'; +import { Replay } from '@sentry/replay'; + +window.Sentry = Sentry; +window.Replay = new Replay({ + flushMinDelay: 200, + flushMaxDelay: 200, + useCompression: false, + maskAllInputs: false, +}); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + sampleRate: 0, + replaysSessionSampleRate: 1.0, + replaysOnErrorSampleRate: 0.0, + + integrations: [window.Replay], +}); diff --git a/packages/integration-tests/suites/replay/privacyInput/template.html b/packages/integration-tests/suites/replay/privacyInput/template.html new file mode 100644 index 000000000000..735abb395522 --- /dev/null +++ b/packages/integration-tests/suites/replay/privacyInput/template.html @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/packages/integration-tests/suites/replay/privacyInput/test.ts b/packages/integration-tests/suites/replay/privacyInput/test.ts new file mode 100644 index 000000000000..f95e857d5637 --- /dev/null +++ b/packages/integration-tests/suites/replay/privacyInput/test.ts @@ -0,0 +1,111 @@ +import { expect } from '@playwright/test'; +import { IncrementalSource } from '@sentry-internal/rrweb'; +import type { inputData } from '@sentry-internal/rrweb/typings/types'; + +import { sentryTest } from '../../../utils/fixtures'; +import type { IncrementalRecordingSnapshot } from '../../../utils/replayHelpers'; +import { + getIncrementalRecordingSnapshots, + shouldSkipReplayTest, + waitForReplayRequest, +} from '../../../utils/replayHelpers'; + +function isInputMutation( + snap: IncrementalRecordingSnapshot, +): snap is IncrementalRecordingSnapshot & { data: inputData } { + return snap.data.source == IncrementalSource.Input; +} + +sentryTest( + 'should mask input initial value and its changes', + async ({ browserName, forceFlushReplay, getLocalTestPath, page }) => { + // TODO(replay): This is flakey on firefox and webkit (~1%) where we do not always get the latest mutation. + if (shouldSkipReplayTest() || ['firefox', 'webkit'].includes(browserName)) { + sentryTest.skip(); + } + + const reqPromise0 = waitForReplayRequest(page, 0); + const reqPromise1 = waitForReplayRequest(page, 1); + const reqPromise2 = waitForReplayRequest(page, 2); + const reqPromise3 = waitForReplayRequest(page, 3); + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + + const url = await getLocalTestPath({ testDir: __dirname }); + + await page.goto(url); + + await reqPromise0; + + const text = 'test'; + + await page.locator('#input').fill(text); + await forceFlushReplay(); + const snapshots = getIncrementalRecordingSnapshots(await reqPromise1).filter(isInputMutation); + const lastSnapshot = snapshots[snapshots.length - 1]; + expect(lastSnapshot.data.text).toBe(text); + + await page.locator('#input-masked').fill(text); + await forceFlushReplay(); + const snapshots2 = getIncrementalRecordingSnapshots(await reqPromise2).filter(isInputMutation); + const lastSnapshot2 = snapshots2[snapshots2.length - 1]; + expect(lastSnapshot2.data.text).toBe('*'.repeat(text.length)); + + await page.locator('#input-ignore').fill(text); + await forceFlushReplay(); + const snapshots3 = getIncrementalRecordingSnapshots(await reqPromise3).filter(isInputMutation); + expect(snapshots3.length).toBe(0); + }, +); + +sentryTest( + 'should mask textarea initial value and its changes', + async ({ browserName, forceFlushReplay, getLocalTestPath, page }) => { + // TODO(replay): This is flakey on firefox and webkit (~1%) where we do not always get the latest mutation. + if (shouldSkipReplayTest() || ['firefox', 'webkit'].includes(browserName)) { + sentryTest.skip(); + } + + const reqPromise0 = waitForReplayRequest(page, 0); + const reqPromise1 = waitForReplayRequest(page, 1); + const reqPromise2 = waitForReplayRequest(page, 2); + const reqPromise3 = waitForReplayRequest(page, 3); + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + + const url = await getLocalTestPath({ testDir: __dirname }); + + await page.goto(url); + await reqPromise0; + + const text = 'test'; + await page.locator('#textarea').fill(text); + await forceFlushReplay(); + const snapshots = getIncrementalRecordingSnapshots(await reqPromise1).filter(isInputMutation); + const lastSnapshot = snapshots[snapshots.length - 1]; + expect(lastSnapshot.data.text).toBe(text); + + await page.locator('#textarea-masked').fill(text); + await forceFlushReplay(); + const snapshots2 = getIncrementalRecordingSnapshots(await reqPromise2).filter(isInputMutation); + const lastSnapshot2 = snapshots2[snapshots2.length - 1]; + expect(lastSnapshot2.data.text).toBe('*'.repeat(text.length)); + + await page.locator('#textarea-ignore').fill(text); + await forceFlushReplay(); + const snapshots3 = getIncrementalRecordingSnapshots(await reqPromise3).filter(isInputMutation); + expect(snapshots3.length).toBe(0); + }, +); diff --git a/packages/integration-tests/suites/replay/privacyInputMaskAll/init.js b/packages/integration-tests/suites/replay/privacyInputMaskAll/init.js new file mode 100644 index 000000000000..6345c0f75f4e --- /dev/null +++ b/packages/integration-tests/suites/replay/privacyInputMaskAll/init.js @@ -0,0 +1,19 @@ +import * as Sentry from '@sentry/browser'; +import { Replay } from '@sentry/replay'; + +window.Sentry = Sentry; +window.Replay = new Replay({ + flushMinDelay: 200, + flushMaxDelay: 200, + useCompression: false, + maskAllInputs: true, +}); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + sampleRate: 0, + replaysSessionSampleRate: 1.0, + replaysOnErrorSampleRate: 0.0, + + integrations: [window.Replay], +}); diff --git a/packages/integration-tests/suites/replay/privacyInputMaskAll/template.html b/packages/integration-tests/suites/replay/privacyInputMaskAll/template.html new file mode 100644 index 000000000000..404bed05a6d0 --- /dev/null +++ b/packages/integration-tests/suites/replay/privacyInputMaskAll/template.html @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/packages/integration-tests/suites/replay/privacyInputMaskAll/test.ts b/packages/integration-tests/suites/replay/privacyInputMaskAll/test.ts new file mode 100644 index 000000000000..9b4470118422 --- /dev/null +++ b/packages/integration-tests/suites/replay/privacyInputMaskAll/test.ts @@ -0,0 +1,135 @@ +import { expect } from '@playwright/test'; +import { IncrementalSource } from '@sentry-internal/rrweb'; +import type { inputData } from '@sentry-internal/rrweb/typings/types'; + +import { sentryTest } from '../../../utils/fixtures'; +import type { IncrementalRecordingSnapshot } from '../../../utils/replayHelpers'; +import { + getIncrementalRecordingSnapshots, + shouldSkipReplayTest, + waitForReplayRequest, +} from '../../../utils/replayHelpers'; + +function isInputMutation( + snap: IncrementalRecordingSnapshot, +): snap is IncrementalRecordingSnapshot & { data: inputData } { + return snap.data.source == IncrementalSource.Input; +} + +sentryTest( + 'should mask input initial value and its changes from `maskAllInputs` and allow unmasked selector', + async ({ browserName, forceFlushReplay, getLocalTestPath, page }) => { + // TODO(replay): This is flakey on firefox and webkit (~1%) where we do not always get the latest mutation. + if (shouldSkipReplayTest() || ['firefox', 'webkit'].includes(browserName)) { + sentryTest.skip(); + } + + // We want to ensure to check the correct event payloads + let firstInputMutationSegmentId: number | undefined = undefined; + const reqPromise0 = waitForReplayRequest(page, 0); + const reqPromise1 = waitForReplayRequest(page, (event, res) => { + const check = + firstInputMutationSegmentId === undefined && getIncrementalRecordingSnapshots(res).some(isInputMutation); + + if (check) { + firstInputMutationSegmentId = event.segment_id; + } + + return check; + }); + const reqPromise2 = waitForReplayRequest(page, (event, res) => { + return ( + typeof firstInputMutationSegmentId === 'number' && + firstInputMutationSegmentId < event.segment_id && + getIncrementalRecordingSnapshots(res).some(isInputMutation) + ); + }); + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + + const url = await getLocalTestPath({ testDir: __dirname }); + + await page.goto(url); + await reqPromise0; + + const text = 'test'; + + await page.locator('#input').fill(text); + await forceFlushReplay(); + + const snapshots = getIncrementalRecordingSnapshots(await reqPromise1).filter(isInputMutation); + const lastSnapshot = snapshots[snapshots.length - 1]; + expect(lastSnapshot.data.text).toBe('*'.repeat(text.length)); + + await page.locator('#input-unmasked').fill(text); + await forceFlushReplay(); + const snapshots2 = getIncrementalRecordingSnapshots(await reqPromise2).filter(isInputMutation); + const lastSnapshot2 = snapshots2[snapshots2.length - 1]; + expect(lastSnapshot2.data.text).toBe(text); + }, +); + +sentryTest( + 'should mask textarea initial value and its changes from `maskAllInputs` and allow unmasked selector', + async ({ browserName, forceFlushReplay, getLocalTestPath, page }) => { + // TODO(replay): This is flakey on firefox and webkit (~1%) where we do not always get the latest mutation. + if (shouldSkipReplayTest() || ['firefox', 'webkit'].includes(browserName)) { + sentryTest.skip(); + } + + // We want to ensure to check the correct event payloads + let firstInputMutationSegmentId: number | undefined = undefined; + const reqPromise0 = waitForReplayRequest(page, 0); + const reqPromise1 = waitForReplayRequest(page, (event, res) => { + const check = + firstInputMutationSegmentId === undefined && getIncrementalRecordingSnapshots(res).some(isInputMutation); + + if (check) { + firstInputMutationSegmentId = event.segment_id; + } + + return check; + }); + const reqPromise2 = waitForReplayRequest(page, (event, res) => { + return ( + typeof firstInputMutationSegmentId === 'number' && + firstInputMutationSegmentId < event.segment_id && + getIncrementalRecordingSnapshots(res).some(isInputMutation) + ); + }); + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + + const url = await getLocalTestPath({ testDir: __dirname }); + + await page.goto(url); + + await reqPromise0; + + const text = 'test'; + + await page.locator('#textarea').fill(text); + await forceFlushReplay(); + const snapshots = getIncrementalRecordingSnapshots(await reqPromise1).filter(isInputMutation); + const lastSnapshot = snapshots[snapshots.length - 1]; + expect(lastSnapshot.data.text).toBe('*'.repeat(text.length)); + + await page.locator('#textarea-unmasked').fill(text); + await forceFlushReplay(); + const snapshots2 = getIncrementalRecordingSnapshots(await reqPromise2).filter(isInputMutation); + const lastSnapshot2 = snapshots2[snapshots2.length - 1]; + expect(lastSnapshot2.data.text).toBe(text); + }, +); diff --git a/packages/integration-tests/suites/replay/sessionExpiry/test.ts b/packages/integration-tests/suites/replay/sessionExpiry/test.ts index c574ac570ec6..d817e7175840 100644 --- a/packages/integration-tests/suites/replay/sessionExpiry/test.ts +++ b/packages/integration-tests/suites/replay/sessionExpiry/test.ts @@ -14,7 +14,7 @@ import { // Session should expire after 2s - keep in sync with init.js const SESSION_TIMEOUT = 2000; -sentryTest('handles an expired session RUN', async ({ getLocalTestPath, page }) => { +sentryTest('handles an expired session', async ({ getLocalTestPath, page }) => { if (shouldSkipReplayTest()) { sentryTest.skip(); } diff --git a/packages/integration-tests/utils/fixtures.ts b/packages/integration-tests/utils/fixtures.ts index 0d4c75353700..05ac906ad2d2 100644 --- a/packages/integration-tests/utils/fixtures.ts +++ b/packages/integration-tests/utils/fixtures.ts @@ -25,6 +25,7 @@ export type TestFixtures = { _autoSnapshotSuffix: void; testDir: string; getLocalTestPath: (options: { testDir: string }) => Promise; + forceFlushReplay: () => Promise; runInChromium: (fn: (...args: unknown[]) => unknown, args?: unknown[]) => unknown; runInFirefox: (fn: (...args: unknown[]) => unknown, args?: unknown[]) => unknown; runInWebkit: (fn: (...args: unknown[]) => unknown, args?: unknown[]) => unknown; @@ -92,6 +93,20 @@ const sentryTest = base.extend({ return fn(...args); }); }, + + forceFlushReplay: ({ page }, use) => { + return use(() => + page.evaluate(` + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: function () { + return 'hidden'; + }, + }); + document.dispatchEvent(new Event('visibilitychange')); + `), + ); + }, }); export { sentryTest }; diff --git a/packages/integration-tests/utils/replayHelpers.ts b/packages/integration-tests/utils/replayHelpers.ts index c5c3e1f50ae9..8722fe245a23 100644 --- a/packages/integration-tests/utils/replayHelpers.ts +++ b/packages/integration-tests/utils/replayHelpers.ts @@ -1,3 +1,5 @@ +import type { fullSnapshotEvent, incrementalSnapshotEvent } from '@sentry-internal/rrweb'; +import { EventType } from '@sentry-internal/rrweb'; import type { InternalEventContext, RecordingEvent, @@ -20,10 +22,18 @@ export type PerformanceSpan = { data: Record; }; -type RecordingSnapshot = eventWithTime & { +export type FullRecordingSnapshot = eventWithTime & { timestamp: 0; + data: fullSnapshotEvent['data']; }; +export type IncrementalRecordingSnapshot = eventWithTime & { + timestamp: 0; + data: incrementalSnapshotEvent['data']; +}; + +export type RecordingSnapshot = FullRecordingSnapshot | IncrementalRecordingSnapshot; + /** * Waits for a replay request to be sent by the page and returns it. * @@ -36,7 +46,13 @@ type RecordingSnapshot = eventWithTime & { * @param segmentId the segment_id of the replay event * @returns */ -export function waitForReplayRequest(page: Page, segmentId?: number): Promise { +export function waitForReplayRequest( + page: Page, + segmentIdOrCallback?: number | ((event: ReplayEvent, res: Response) => boolean), +): Promise { + const segmentId = typeof segmentIdOrCallback === 'number' ? segmentIdOrCallback : undefined; + const callback = typeof segmentIdOrCallback === 'function' ? segmentIdOrCallback : undefined; + return page.waitForResponse(res => { const req = res.request(); @@ -52,6 +68,10 @@ export function waitForReplayRequest(page: Page, segmentId?: number): Promise event.type === 5).map(event => event.data as CustomRecordingEvent); + return recordingEvents.filter(isCustomSnapshot).map(event => event.data); } -function getReplayBreadcrumbs(recordingEvents: RecordingEvent[], category?: string): Breadcrumb[] { +function getReplayBreadcrumbs(recordingEvents: RecordingSnapshot[], category?: string): Breadcrumb[] { return getAllCustomRrwebRecordingEvents(recordingEvents) .filter(data => data.tag === 'breadcrumb') .map(data => data.payload) @@ -144,16 +176,16 @@ function getReplayPerformanceSpans(recordingEvents: RecordingEvent[]): Performan .map(data => data.payload) as PerformanceSpan[]; } -export function getFullRecordingSnapshots(resOrReq: Request | Response): RecordingSnapshot[] { +export function getFullRecordingSnapshots(resOrReq: Request | Response): FullRecordingSnapshot[] { const replayRequest = getRequest(resOrReq); const events = getDecompressedRecordingEvents(replayRequest); - return events.filter(event => event.type === 2); + return events.filter(isFullSnapshot); } -export function getIncrementalRecordingSnapshots(resOrReq: Request | Response): RecordingSnapshot[] { +export function getIncrementalRecordingSnapshots(resOrReq: Request | Response): IncrementalRecordingSnapshot[] { const replayRequest = getRequest(resOrReq); const events = getDecompressedRecordingEvents(replayRequest); - return events.filter(event => event.type === 3); + return events.filter(isIncrementalSnapshot); } function getDecompressedRecordingEvents(resOrReq: Request | Response): RecordingSnapshot[] { @@ -166,7 +198,7 @@ function getDecompressedRecordingEvents(resOrReq: Request | Response): Recording event => typeof event.data === 'object' && event.data && (event.data as Record).source !== 1, ) .map(event => { - return { ...event, timestamp: 0 }; + return { ...event, timestamp: 0 } as RecordingSnapshot; }) ); }