Files
cinny/src/app/utils/urlTracking.test.ts
T
jaredandClaude Opus 5 5b0d355417 feat(privacy): strip tracking parameters from links on paste, send and render (#103)
Shared links routinely carry ad/analytics identifiers (utm_*, fbclid, gclid,
YouTube si=, Amazon ref=/tag=, X s=/t=, TikTok _r/_t, …) that tie every
recipient's click back to the person who shared the link. New
src/app/utils/urlTracking.ts is a pure, local stripper: a global list +
utm_/pk_/matomo_ prefixes, plus host-scoped rules so e.g. `si` is only
removed on youtube/spotify. matrix.to and non-http(s) schemes are never
rewritten; unparseable input is returned unchanged; Amazon's `th`/`psc`
variant selectors are deliberately kept. 13 unit tests.

Wired at three points, all behind a new Settings → Privacy toggle
(`stripTrackingParams`, default on):
- paste: plain-text pastes are cleaned and re-inserted through Slate's own
  insertData so multi-line pastes still split into paragraphs;
- send: RoomInput submit + schedule paths and MessageEditor saves clean both
  `body` and `formatted_body` (the HTML variant unescapes `&` around each
  URL and re-escapes it so the markup is untouched);
- render: linkify `formatHref`/`format` and explicit `<a href>` in
  formatted_body are cleaned, so links sent from other clients are safe to
  click too. LINKIFY_OPTS is spread into memoised per-timeline objects, so
  the toggle is a module flag kept current by ClientNonUIFeatures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-17 01:46:21 -04:00

112 lines
4.0 KiB
TypeScript

import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
stripTrackingParams,
stripTrackingParamsInHtml,
stripTrackingParamsInText,
} from './urlTracking';
describe('stripTrackingParams', () => {
it('removes utm_* and known global params, keeps the rest', () => {
const out = stripTrackingParams(
'https://example.com/a?utm_source=x&utm_medium=y&fbclid=abc&page=2&gclid=1',
);
assert.equal(out, 'https://example.com/a?page=2');
});
it('drops the dangling ? when every param was tracking', () => {
assert.equal(
stripTrackingParams('https://example.com/a?utm_source=x'),
'https://example.com/a',
);
});
it('returns the identical string when nothing changed', () => {
const url = 'https://example.com/a?page=2#frag';
assert.equal(stripTrackingParams(url), url);
});
it('preserves the fragment', () => {
assert.equal(
stripTrackingParams('https://example.com/a?utm_source=x#section-3'),
'https://example.com/a#section-3',
);
});
it('applies host rules only on that host', () => {
assert.equal(
stripTrackingParams('https://youtu.be/dQw4w9WgXcQ?si=abc123&t=42'),
'https://youtu.be/dQw4w9WgXcQ?t=42',
);
assert.equal(
stripTrackingParams('https://www.youtube.com/watch?v=dQw4w9WgXcQ&feature=share&si=x'),
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
);
// `si` is not tracking elsewhere.
const other = 'https://example.com/?si=keep';
assert.equal(stripTrackingParams(other), other);
});
it('strips amazon affiliate/telemetry params and the /ref= path segment but keeps variant selectors', () => {
assert.equal(
stripTrackingParams(
'https://www.amazon.com/dp/B000000000/ref=sr_1_3?crid=ABC&keywords=x&qid=1&sr=8-3&tag=aff-20&th=1&psc=1&pd_rd_r=zz',
),
'https://www.amazon.com/dp/B000000000?keywords=x&th=1&psc=1',
);
});
it('never rewrites matrix.to permalinks or non-http schemes', () => {
const permalink = 'https://matrix.to/#/!room:example.org/$event?via=example.org&utm_source=x';
assert.equal(stripTrackingParams(permalink), permalink);
assert.equal(
stripTrackingParams('mxc://example.org/abc?utm_source=x'),
'mxc://example.org/abc?utm_source=x',
);
assert.equal(stripTrackingParams('mailto:a@b.c?utm_source=x'), 'mailto:a@b.c?utm_source=x');
});
it('returns unparseable input unchanged', () => {
assert.equal(stripTrackingParams('https://'), 'https://');
assert.equal(stripTrackingParams('not a url'), 'not a url');
});
it('is idempotent', () => {
const once = stripTrackingParams('https://x.com/user/status/1?s=20&t=abc');
assert.equal(once, 'https://x.com/user/status/1');
assert.equal(stripTrackingParams(once), once);
});
});
describe('stripTrackingParamsInText', () => {
it('cleans every URL in a block of text and keeps trailing punctuation', () => {
const text =
'see https://a.example/p?utm_source=x. and (https://b.example/?id=1&fbclid=2) plus https://c.example/?keep=1';
assert.equal(
stripTrackingParamsInText(text),
'see https://a.example/p. and (https://b.example/?id=1) plus https://c.example/?keep=1',
);
});
it('returns the identical string when no URL changed', () => {
const text = 'nothing https://c.example/?keep=1 here';
assert.equal(stripTrackingParamsInText(text), text);
});
});
describe('stripTrackingParamsInHtml', () => {
it('unescapes &amp; for parsing and re-escapes in the output', () => {
const html =
'<a href="https://a.example/p?id=1&amp;utm_source=x&amp;page=2">https://a.example/p?id=1&amp;utm_source=x&amp;page=2</a>';
assert.equal(
stripTrackingParamsInHtml(html),
'<a href="https://a.example/p?id=1&amp;page=2">https://a.example/p?id=1&amp;page=2</a>',
);
});
it('leaves markup untouched when nothing is stripped', () => {
const html = '<p>hi <a href="https://a.example/?x=1">link</a></p>';
assert.equal(stripTrackingParamsInHtml(html), html);
});
});