← 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(', ')}`);
});