← back to Homesonspec
apps/mobile/lib/webview-guard.test.mjs
97 lines
/**
* Structural guard for the WebView tracker-blocking invariant (TK-10387).
*
* The Saved-tab tracker leak existed because hardening a WebView was a per-call-site
* ritual: `browse.tsx` remembered the injected tracker block and the URL filter,
* `saved.tsx` did not, and nothing caught the difference. An invariant enforced only
* by developer memory fails exactly once per new WebView.
*
* So the invariant is enforced here instead: `components/TrackedWebView.tsx` is the
* only file allowed to import the WebView COMPONENT. Everyone else must go through
* the wrapper, which bakes in the tracker block and the request filter. Type-only
* imports (e.g. `import type { WebViewNavigation }`) are fine — a type cannot render
* an unprotected WebView.
*
* If this test fails, do NOT add the props by hand at the new call site. Use
* <TrackedWebView>. That is the whole point.
*/
import assert from 'node:assert/strict';
import test from 'node:test';
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
const ROOT = new URL('..', import.meta.url).pathname;
const SCAN_DIRS = ['app', 'components', 'lib'];
const WRAPPER = 'components/TrackedWebView.tsx';
function walk(dir, out = []) {
let entries;
try {
entries = readdirSync(join(ROOT, dir));
} catch {
return out;
}
for (const name of entries) {
if (name === 'node_modules' || name.startsWith('.')) continue;
const rel = `${dir}/${name}`;
if (statSync(join(ROOT, rel)).isDirectory()) walk(rel, out);
else if (/\.(ts|tsx|js|jsx)$/.test(name)) out.push(rel);
}
return out;
}
const files = SCAN_DIRS.flatMap((d) => walk(d));
test('the wrapper exists and is the one place WebView is imported', () => {
assert.ok(files.includes(WRAPPER), `${WRAPPER} is missing — the guard has nothing to protect`);
const src = readFileSync(join(ROOT, WRAPPER), 'utf8');
assert.match(src, /from 'react-native-webview'/, 'wrapper should import the real WebView');
assert.match(src, /TRACKER_BLOCK/, 'wrapper must carry the tracker-block script');
assert.match(src, /isTrackerUrl/, 'wrapper must apply the tracker URL filter');
});
test('no file outside the wrapper imports the WebView component', () => {
const offenders = [];
for (const rel of files) {
if (rel === WRAPPER) continue;
const src = readFileSync(join(ROOT, rel), 'utf8');
for (const m of src.matchAll(/import\s+(type\s+)?\{([^}]*)\}\s+from\s+'react-native-webview'/g)) {
const isTypeOnly = Boolean(m[1]);
const named = m[2].split(',').map((x) => x.trim()).filter(Boolean);
// `import type { ... }` can never render anything.
if (isTypeOnly) continue;
// A value import of the WebView component itself is the violation.
const bad = named.filter((n) => /^WebView\b/.test(n) && !n.startsWith('type '));
if (bad.length) offenders.push(`${rel}: imports ${bad.join(', ')}`);
}
// default / namespace imports of the module are violations too
if (/import\s+(?!type\b)[A-Za-z_$][\w$]*\s*(,|\s+from)\s*.*'react-native-webview'/.test(src)) {
offenders.push(`${rel}: default or namespace import of react-native-webview`);
}
}
assert.deepEqual(
offenders,
[],
`Raw WebView usage found outside ${WRAPPER}. Use <TrackedWebView> instead — it applies the ` +
`tracker block and URL filter automatically:\n ${offenders.join('\n ')}`,
);
});
test('every WebView element rendered in app/ is a TrackedWebView', () => {
const offenders = [];
for (const rel of files) {
if (rel === WRAPPER) continue;
const src = readFileSync(join(ROOT, rel), 'utf8');
// A JSX element `<WebView ...>` — but NOT a generic type argument like
// `useRef<WebView>(null)`, where `<` is preceded by an identifier character.
// Requiring a non-identifier char before `<` cleanly separates the two, and
// `<TrackedWebView` never contains the literal `<WebView`.
if (/(^|[\s(){}])<WebView[\s/>]/m.test(src)) offenders.push(rel);
}
assert.deepEqual(offenders, [], `Raw <WebView> element rendered outside the wrapper: ${offenders.join(', ')}`);
});