Skip to content

Commit 4714518

Browse files
committed
test(sentry): unit tests for scrubbers and TelemetryConsentBanner
- Extract scrubMatrixIds/scrubDataObject/scrubMatrixUrl from instrument.ts into src/app/utils/sentryScrubbers.ts so they can be tested independently - Add 43 unit tests covering token redaction, Matrix entity ID scrubbing, URL path scrubbing (API + app routes + percent-encoded deep-links), and scrubDataObject recursive traversal - Add 12 RTL integration tests for TelemetryConsentBanner: visibility gating (no DSN, pre-existing pref), Got it, dismiss, and Opt out flows
1 parent f4764bb commit 4714518

4 files changed

Lines changed: 457 additions & 93 deletions

File tree

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2+
import { render, screen, fireEvent } from '@testing-library/react';
3+
import { TelemetryConsentBanner } from './TelemetryConsentBanner';
4+
5+
const SENTRY_KEY = 'sable_sentry_enabled';
6+
const TEST_DSN = 'https://[email protected]/0';
7+
8+
describe('TelemetryConsentBanner', () => {
9+
beforeEach(() => {
10+
localStorage.clear();
11+
vi.stubGlobal('location', { reload: vi.fn() });
12+
});
13+
14+
afterEach(() => {
15+
vi.unstubAllEnvs();
16+
vi.unstubAllGlobals();
17+
});
18+
19+
// ── visibility ────────────────────────────────────────────────────────────
20+
21+
it('renders nothing when VITE_SENTRY_DSN is not configured', () => {
22+
vi.stubEnv('VITE_SENTRY_DSN', '');
23+
const { container } = render(<TelemetryConsentBanner />);
24+
expect(container).toBeEmptyDOMElement();
25+
});
26+
27+
it('renders nothing when the user has already acknowledged (opted in)', () => {
28+
vi.stubEnv('VITE_SENTRY_DSN', TEST_DSN);
29+
localStorage.setItem(SENTRY_KEY, 'true');
30+
const { container } = render(<TelemetryConsentBanner />);
31+
expect(container).toBeEmptyDOMElement();
32+
});
33+
34+
it('renders nothing when the user has already opted out', () => {
35+
vi.stubEnv('VITE_SENTRY_DSN', TEST_DSN);
36+
localStorage.setItem(SENTRY_KEY, 'false');
37+
const { container } = render(<TelemetryConsentBanner />);
38+
expect(container).toBeEmptyDOMElement();
39+
});
40+
41+
it('renders the banner when DSN is configured and no preference is saved', () => {
42+
vi.stubEnv('VITE_SENTRY_DSN', TEST_DSN);
43+
render(<TelemetryConsentBanner />);
44+
expect(screen.getByRole('region', { name: /crash reporting notice/i })).toBeInTheDocument();
45+
expect(screen.getByText(/crash reporting is enabled/i)).toBeInTheDocument();
46+
});
47+
48+
// ── accessibility ─────────────────────────────────────────────────────────
49+
50+
it('has both action buttons visible', () => {
51+
vi.stubEnv('VITE_SENTRY_DSN', TEST_DSN);
52+
render(<TelemetryConsentBanner />);
53+
expect(screen.getByRole('button', { name: /got it/i })).toBeInTheDocument();
54+
expect(screen.getByRole('button', { name: /opt out/i })).toBeInTheDocument();
55+
expect(screen.getByRole('button', { name: /dismiss/i })).toBeInTheDocument();
56+
});
57+
58+
it('includes a link to the privacy policy', () => {
59+
vi.stubEnv('VITE_SENTRY_DSN', TEST_DSN);
60+
render(<TelemetryConsentBanner />);
61+
expect(screen.getByRole('link', { name: /learn more/i })).toBeInTheDocument();
62+
});
63+
64+
// ── "Got it" action ───────────────────────────────────────────────────────
65+
66+
it('"Got it" saves opted-in preference to localStorage', () => {
67+
vi.stubEnv('VITE_SENTRY_DSN', TEST_DSN);
68+
render(<TelemetryConsentBanner />);
69+
fireEvent.click(screen.getByRole('button', { name: /got it/i }));
70+
expect(localStorage.getItem(SENTRY_KEY)).toBe('true');
71+
});
72+
73+
it('"Got it" does not reload the page', () => {
74+
vi.stubEnv('VITE_SENTRY_DSN', TEST_DSN);
75+
render(<TelemetryConsentBanner />);
76+
fireEvent.click(screen.getByRole('button', { name: /got it/i }));
77+
expect(window.location.reload).not.toHaveBeenCalled();
78+
});
79+
80+
// ── dismiss (✕) action ────────────────────────────────────────────────────
81+
82+
it('dismiss button (✕) saves opted-in preference to localStorage', () => {
83+
vi.stubEnv('VITE_SENTRY_DSN', TEST_DSN);
84+
render(<TelemetryConsentBanner />);
85+
fireEvent.click(screen.getByRole('button', { name: /dismiss/i }));
86+
expect(localStorage.getItem(SENTRY_KEY)).toBe('true');
87+
});
88+
89+
it('dismiss button does not reload the page', () => {
90+
vi.stubEnv('VITE_SENTRY_DSN', TEST_DSN);
91+
render(<TelemetryConsentBanner />);
92+
fireEvent.click(screen.getByRole('button', { name: /dismiss/i }));
93+
expect(window.location.reload).not.toHaveBeenCalled();
94+
});
95+
96+
// ── "Opt out" action ──────────────────────────────────────────────────────
97+
98+
it('"Opt out" saves opted-out preference to localStorage', () => {
99+
vi.stubEnv('VITE_SENTRY_DSN', TEST_DSN);
100+
render(<TelemetryConsentBanner />);
101+
fireEvent.click(screen.getByRole('button', { name: /opt out/i }));
102+
expect(localStorage.getItem(SENTRY_KEY)).toBe('false');
103+
});
104+
105+
it('"Opt out" reloads the page', () => {
106+
vi.stubEnv('VITE_SENTRY_DSN', TEST_DSN);
107+
render(<TelemetryConsentBanner />);
108+
fireEvent.click(screen.getByRole('button', { name: /opt out/i }));
109+
expect(window.location.reload).toHaveBeenCalledOnce();
110+
});
111+
});
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { scrubMatrixIds, scrubDataObject, scrubMatrixUrl } from './sentryScrubbers';
3+
4+
// ─── scrubMatrixIds ───────────────────────────────────────────────────────────
5+
6+
describe('scrubMatrixIds – credential tokens', () => {
7+
it('redacts access_token in query-string form', () => {
8+
expect(scrubMatrixIds('GET /?access_token=abc123xyz')).toBe('GET /?access_token=[REDACTED]');
9+
});
10+
11+
it('redacts password in key=value form', () => {
12+
expect(scrubMatrixIds('password=hunter2')).toBe('password=[REDACTED]');
13+
});
14+
15+
it('redacts refresh_token', () => {
16+
expect(scrubMatrixIds('refresh_token=tok_refresh_xyz')).toBe('refresh_token=[REDACTED]');
17+
});
18+
19+
it('redacts sync_token and next_batch', () => {
20+
expect(scrubMatrixIds('sync_token=s1234_5678')).toBe('sync_token=[REDACTED]');
21+
expect(scrubMatrixIds('next_batch=s1234_5678')).toBe('next_batch=[REDACTED]');
22+
});
23+
24+
it('is case-insensitive for token names', () => {
25+
expect(scrubMatrixIds('Access_Token=abc')).toBe('Access_Token=[REDACTED]');
26+
});
27+
28+
it('leaves unrelated query params untouched', () => {
29+
expect(scrubMatrixIds('format=json&limit=50')).toBe('format=json&limit=50');
30+
});
31+
});
32+
33+
describe('scrubMatrixIds – Matrix entity IDs', () => {
34+
it('replaces user IDs', () => {
35+
expect(scrubMatrixIds('@alice:example.com')).toBe('@[USER_ID]');
36+
expect(scrubMatrixIds('@bob:matrix.org')).toBe('@[USER_ID]');
37+
});
38+
39+
it('replaces room IDs', () => {
40+
expect(scrubMatrixIds('!roomid:example.com')).toBe('![ROOM_ID]');
41+
});
42+
43+
it('replaces room aliases', () => {
44+
expect(scrubMatrixIds('#general:example.com')).toBe('#[ROOM_ALIAS]');
45+
});
46+
47+
it('replaces event IDs (10+ base64 chars)', () => {
48+
expect(scrubMatrixIds('$abcdefghij')).toBe('$[EVENT_ID]');
49+
expect(scrubMatrixIds('$1234567890abcdef')).toBe('$[EVENT_ID]');
50+
});
51+
52+
it('leaves short dollar strings untouched (< 10 chars)', () => {
53+
expect(scrubMatrixIds('$short')).toBe('$short');
54+
});
55+
56+
it('scrubs multiple IDs in one string', () => {
57+
const input = 'User @alice:example.com joined !abc:example.com';
58+
const result = scrubMatrixIds(input);
59+
expect(result).toContain('@[USER_ID]');
60+
expect(result).toContain('![ROOM_ID]');
61+
expect(result).not.toContain('@alice');
62+
expect(result).not.toContain('!abc');
63+
});
64+
65+
it('passes through plain strings with no sensitive content', () => {
66+
const safe = 'Something went wrong loading the timeline';
67+
expect(scrubMatrixIds(safe)).toBe(safe);
68+
});
69+
});
70+
71+
// ─── scrubDataObject ──────────────────────────────────────────────────────────
72+
73+
describe('scrubDataObject', () => {
74+
it('scrubs a top-level string', () => {
75+
expect(scrubDataObject('@alice:example.com')).toBe('@[USER_ID]');
76+
});
77+
78+
it('scrubs string values inside a plain object', () => {
79+
const result = scrubDataObject({ userId: '@alice:example.com', count: 3 }) as Record<
80+
string,
81+
unknown
82+
>;
83+
expect(result.userId).toBe('@[USER_ID]');
84+
expect(result.count).toBe(3); // non-strings are preserved
85+
});
86+
87+
it('scrubs string values inside a nested object', () => {
88+
const result = scrubDataObject({
89+
context: { roomId: '!room:example.com' },
90+
}) as { context: Record<string, unknown> };
91+
expect(result.context.roomId).toBe('![ROOM_ID]');
92+
});
93+
94+
it('scrubs string values inside an array', () => {
95+
const result = scrubDataObject(['@alice:example.com', '!room:example.com', 42]) as unknown[];
96+
expect(result[0]).toBe('@[USER_ID]');
97+
expect(result[1]).toBe('![ROOM_ID]');
98+
expect(result[2]).toBe(42);
99+
});
100+
101+
it('passes through null unchanged', () => {
102+
expect(scrubDataObject(null)).toBeNull();
103+
});
104+
105+
it('passes through numbers and booleans unchanged', () => {
106+
expect(scrubDataObject(42)).toBe(42);
107+
expect(scrubDataObject(true)).toBe(true);
108+
});
109+
110+
it('handles an empty object', () => {
111+
expect(scrubDataObject({})).toEqual({});
112+
});
113+
});
114+
115+
// ─── scrubMatrixUrl ───────────────────────────────────────────────────────────
116+
117+
describe('scrubMatrixUrl – Matrix C-S API paths', () => {
118+
it('scrubs room ID in /rooms/ path', () => {
119+
expect(scrubMatrixUrl('/_matrix/client/v3/rooms/!abc:example.com/messages')).toBe(
120+
'/_matrix/client/v3/rooms/![ROOM_ID]/messages'
121+
);
122+
});
123+
124+
it('scrubs event ID in /event/ path', () => {
125+
expect(scrubMatrixUrl('/rooms/!abc:example.com/event/$eventIdHere')).toContain(
126+
'/event/$[EVENT_ID]'
127+
);
128+
});
129+
130+
it('scrubs event ID in /relations/ path', () => {
131+
expect(scrubMatrixUrl('/rooms/!abc:example.com/relations/$eventIdHere')).toContain(
132+
'/relations/$[EVENT_ID]'
133+
);
134+
});
135+
136+
it('scrubs user ID in /profile/ path', () => {
137+
expect(scrubMatrixUrl('/_matrix/client/v3/profile/@alice:example.com')).toBe(
138+
'/_matrix/client/v3/profile/[USER_ID]'
139+
);
140+
});
141+
142+
it('scrubs percent-encoded user ID in /profile/ path', () => {
143+
expect(scrubMatrixUrl('/profile/%40alice%3Aexample.com')).toBe('/profile/[USER_ID]');
144+
});
145+
146+
it('scrubs user ID in /user/ path', () => {
147+
expect(scrubMatrixUrl('/_matrix/client/v3/user/@alice:example.com/filter')).toBe(
148+
'/_matrix/client/v3/user/[USER_ID]/filter'
149+
);
150+
});
151+
152+
it('scrubs user ID in /presence/ path', () => {
153+
expect(scrubMatrixUrl('/_matrix/client/v3/presence/@alice:example.com/status')).toBe(
154+
'/_matrix/client/v3/presence/[USER_ID]/status'
155+
);
156+
});
157+
158+
it('scrubs the version segment in /room_keys/keys/ paths', () => {
159+
// The regex scrubs up to the first '/' — the version segment is redacted.
160+
// Sub-paths (roomId, sessionId) are handled by subsequent URL patterns.
161+
expect(scrubMatrixUrl('/_matrix/client/v3/room_keys/keys/latest')).toBe(
162+
'/_matrix/client/v3/room_keys/keys/[REDACTED]'
163+
);
164+
});
165+
166+
it('scrubs /sendToDevice/ transaction IDs', () => {
167+
expect(scrubMatrixUrl('/sendToDevice/m.room.encrypted/txnId123')).toBe(
168+
'/sendToDevice/m.room.encrypted/[TXN_ID]'
169+
);
170+
});
171+
172+
it('scrubs MSC3916 media download path', () => {
173+
expect(scrubMatrixUrl('/_matrix/client/v1/media/download/matrix.org/someMediaId')).toBe(
174+
'/_matrix/client/v1/media/download/[SERVER]/[MEDIA_ID]'
175+
);
176+
});
177+
178+
it('scrubs legacy /media/v3/ download path', () => {
179+
expect(scrubMatrixUrl('/_matrix/media/v3/download/matrix.org/someMediaId')).toBe(
180+
'/_matrix/media/v3/download/[SERVER]/[MEDIA_ID]'
181+
);
182+
});
183+
});
184+
185+
describe('scrubMatrixUrl – app route path segments', () => {
186+
it('scrubs bare room ID in app route', () => {
187+
expect(scrubMatrixUrl('/home/!roomid:example.com/timeline')).toBe('/home/![ROOM_ID]/timeline');
188+
});
189+
190+
it('scrubs hybrid room ID (decoded sigil, encoded colon)', () => {
191+
expect(scrubMatrixUrl('/home/!roomid%3Aexample.com/timeline')).toBe(
192+
'/home/![ROOM_ID]/timeline'
193+
);
194+
});
195+
196+
it('scrubs bare user ID in app route', () => {
197+
expect(scrubMatrixUrl('/dm/@alice:example.com')).toBe('/dm/@[USER_ID]');
198+
});
199+
200+
it('scrubs bare room alias in app route', () => {
201+
expect(scrubMatrixUrl('/home/#general:example.com')).toBe('/home/[ROOM_ALIAS]');
202+
});
203+
});
204+
205+
describe('scrubMatrixUrl – deep-link (percent-encoded) forms', () => {
206+
it('scrubs %40-encoded user ID', () => {
207+
expect(scrubMatrixUrl('/open/%40alice%3Aexample.com')).toBe('/open/[USER_ID]');
208+
});
209+
210+
it('scrubs %21-encoded room ID', () => {
211+
expect(scrubMatrixUrl('/open/%21room%3Aexample.com')).toBe('/open/![ROOM_ID]');
212+
});
213+
214+
it('scrubs %23-encoded room alias', () => {
215+
expect(scrubMatrixUrl('/open/%23general%3Aexample.com')).toBe('/open/[ROOM_ALIAS]');
216+
});
217+
218+
it('scrubs %24-encoded event ID', () => {
219+
expect(scrubMatrixUrl('/open/%24eventIdLongEnough')).toBe('/open/[EVENT_ID]');
220+
});
221+
});
222+
223+
describe('scrubMatrixUrl – preview_url', () => {
224+
it('strips query string from preview_url endpoint', () => {
225+
expect(scrubMatrixUrl('/_matrix/media/v3/preview_url?url=https://example.com&ts=1234')).toBe(
226+
'/_matrix/media/v3/preview_url'
227+
);
228+
});
229+
230+
it('leaves the path intact and only removes query string', () => {
231+
const result = scrubMatrixUrl('/preview_url?url=https://evil.example.com');
232+
expect(result).toBe('/preview_url');
233+
});
234+
});
235+
236+
describe('scrubMatrixUrl – safe inputs', () => {
237+
it('passes through a plain path with no Matrix IDs', () => {
238+
const safe = '/home/timeline';
239+
expect(scrubMatrixUrl(safe)).toBe(safe);
240+
});
241+
242+
it('passes through an empty string', () => {
243+
expect(scrubMatrixUrl('')).toBe('');
244+
});
245+
});

0 commit comments

Comments
 (0)