Skip to content

Commit 81adc91

Browse files
authored
Add captcha detection test coverage for webDetection (#2793)
* Add captcha detection test coverage for webDetection Adds Jasmine unit tests and Playwright integration tests (plus fixture pages) for per-provider captcha detection via the existing webDetection framework. No source change — the detectors themselves are config in privacy-configuration; this proves the element + visibility matching and the auto-run -> fireEvent path behave correctly for captchas. Vendors covered: reCAPTCHA, hCaptcha, Cloudflare Turnstile (widget), the Cloudflare full-page interstitial, and a generic "other" fallback. Tests assert that a visible challenge matches, that hidden/invisible/absent ones do not, and crucially that there is no cross-vendor double counting. In particular the current Cloudflare interstitial embeds Turnstile via <div id="turnstile-wrapper" class="cf-turnstile">, so it must be attributed to the cloudflare detector only; the turnstile selector therefore excludes #turnstile-wrapper. * Fix tsc error in captcha detector test fixture The captcha fixture widened `visibility: 'visible'` to `string`, so passing `captcha.recaptcha`/`.hcaptcha`/`.turnstile` directly to matchInDOM failed tsc — the param expects the literal union "hidden" | "visible" | "any". Annotating the const as Record<string, MatchCondition> checks the literals against the schema type instead of widening them. Also reflow two over-long lines flagged by prettier. * Fix prettier formatting in captcha integration spec The lint chain's `prettier . --check` step flagged over-long detector lines in the new web-detection integration spec. The earlier tsc failure had stopped the chain before prettier ran, masking this. Reflow the affected lines.
1 parent 93e8398 commit 81adc91

8 files changed

Lines changed: 336 additions & 0 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<title>Captcha - Cloudflare interstitial</title>
6+
</head>
7+
<body>
8+
<!-- Current Cloudflare full-page interstitial / managed challenge. It embeds Turnstile via
9+
<div id="turnstile-wrapper" class="cf-turnstile">, so it must fire ONLY captcha_cloudflare:
10+
the turnstile detector excludes #turnstile-wrapper. -->
11+
<div id="cf-wrapper" style="width: 100%; min-height: 400px;">
12+
<div id="challenge-running" style="width: 320px; height: 40px;">Checking your browser before accessing the site.</div>
13+
<div id="challenge-stage" style="width: 320px; height: 120px;">
14+
<div id="turnstile-wrapper" class="cf-turnstile" style="width: 300px; height: 65px;"></div>
15+
</div>
16+
</div>
17+
</body>
18+
</html>
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<title>Captcha - hCaptcha</title>
6+
</head>
7+
<body>
8+
<h1>Sign in</h1>
9+
<p>Please complete the challenge below to continue.</p>
10+
<!-- Visible hCaptcha widget container -->
11+
<div class="h-captcha" style="width: 303px; height: 78px;" data-sitekey="test"></div>
12+
</body>
13+
</html>
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<title>Captcha - other / unknown vendor</title>
6+
</head>
7+
<body>
8+
<h1>Security check</h1>
9+
<!-- Generic challenge from a vendor without a dedicated detector -->
10+
<div style="width: 320px; height: 120px;">
11+
<p>Please press and hold to confirm you are human.</p>
12+
</div>
13+
</body>
14+
</html>
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<title>Captcha - hidden reCAPTCHA</title>
6+
</head>
7+
<body>
8+
<h1>Sign in</h1>
9+
<p>No challenge is shown to the user on this page.</p>
10+
<!-- reCAPTCHA present in the DOM but hidden — must NOT be counted as a visible challenge -->
11+
<div class="g-recaptcha" style="display: none;" data-sitekey="test">
12+
<iframe title="reCAPTCHA" src="about:blank#recaptcha"></iframe>
13+
</div>
14+
</body>
15+
</html>
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<title>Captcha - reCAPTCHA</title>
6+
</head>
7+
<body>
8+
<h1>Sign in</h1>
9+
<p>Please complete the challenge below to continue.</p>
10+
<!-- Visible reCAPTCHA v2 widget container -->
11+
<div class="g-recaptcha" style="width: 304px; height: 78px;" data-sitekey="test"></div>
12+
</body>
13+
</html>
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<title>Captcha - Cloudflare Turnstile</title>
6+
</head>
7+
<body>
8+
<h1>Sign in</h1>
9+
<p>Please complete the challenge below to continue.</p>
10+
<!-- Cloudflare Turnstile widget (the widget, NOT the full-page interstitial) -->
11+
<div class="cf-turnstile" style="width: 300px; height: 65px;" data-sitekey="test"></div>
12+
</body>
13+
</html>

injected/integration-test/web-detection.spec.js

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,71 @@ import { readFileSync } from 'node:fs';
44

55
const CONFIG = './integration-test/test-pages/web-detection/config/config.json';
66

7+
/**
8+
* Captcha detectors mirroring privacy-configuration/features/web-detection.json (the `captcha`
9+
* group) with the Windows conditionalChanges applied inline (auto trigger + per-provider
10+
* fireEvent), so this test exercises the same auto-run → fireEvent path the product ships.
11+
* Detection is element + visibility only (a *visible* challenge); keep selectors in sync with config.
12+
*/
13+
const CAPTCHA_DETECTORS = {
14+
recaptcha: {
15+
match: {
16+
element: { selector: ['.g-recaptcha', "iframe[src*='recaptcha']", "iframe[title*='recaptcha' i]"], visibility: 'visible' },
17+
},
18+
triggers: { auto: { state: 'enabled', when: { intervalMs: [100] } } },
19+
actions: { fireEvent: { type: 'captcha_recaptcha' } },
20+
},
21+
hcaptcha: {
22+
match: {
23+
element: { selector: ['.h-captcha', "iframe[src*='hcaptcha.com']", "iframe[title*='hcaptcha' i]"], visibility: 'visible' },
24+
},
25+
triggers: { auto: { state: 'enabled', when: { intervalMs: [100] } } },
26+
actions: { fireEvent: { type: 'captcha_hcaptcha' } },
27+
},
28+
turnstile: {
29+
// :not(#turnstile-wrapper) excludes the Cloudflare interstitial's own Turnstile wrapper, so an
30+
// interstitial is counted as `cloudflare`, not `turnstile`.
31+
match: {
32+
element: {
33+
selector: ['.cf-turnstile:not(#turnstile-wrapper)', '.cf-turnstile:not(#turnstile-wrapper) iframe'],
34+
visibility: 'visible',
35+
},
36+
},
37+
triggers: { auto: { state: 'enabled', when: { intervalMs: [100] } } },
38+
actions: { fireEvent: { type: 'captcha_turnstile' } },
39+
},
40+
cloudflare: {
41+
match: {
42+
element: {
43+
selector: [
44+
'#challenge-form',
45+
'#cf-wrapper',
46+
'.cf-browser-verification',
47+
'#challenge-running',
48+
'#cf-challenge-running',
49+
'#challenge-stage',
50+
],
51+
visibility: 'visible',
52+
},
53+
},
54+
triggers: { auto: { state: 'enabled', when: { intervalMs: [100] } } },
55+
actions: { fireEvent: { type: 'captcha_cloudflare' } },
56+
},
57+
other: {
58+
match: {
59+
text: {
60+
pattern: [
61+
'press (and|&) hold to (confirm|verify)',
62+
'slide( right)? to (verify|complete)',
63+
'complete the security check to (access|continue)',
64+
],
65+
},
66+
},
67+
triggers: { auto: { state: 'enabled', when: { intervalMs: [100] } } },
68+
actions: { fireEvent: { type: 'captcha_other' } },
69+
},
70+
};
71+
772
/**
873
* Helper class for web-detection tests.
974
*/
@@ -115,6 +180,28 @@ class WebDetectionTestHelper {
115180
return { collector, helper };
116181
}
117182

183+
/**
184+
* Set up collector for captcha detection tests: enables webEvents and installs the captcha
185+
* detector group (auto-run + per-provider fireEvent), with fake timers.
186+
*
187+
* @param {import('@playwright/test').Page} page
188+
* @param {Record<string, any>} projectUse
189+
* @returns {Promise<{collector: ResultsCollector, helper: WebDetectionTestHelper}>}
190+
*/
191+
static async setupCaptchaTest(page, projectUse) {
192+
const config = JSON.parse(readFileSync(CONFIG, 'utf8'));
193+
config.features.webEvents = { state: 'enabled', hash: 'test', exceptions: [] };
194+
config.features.webDetection.settings.detectors.captcha = CAPTCHA_DETECTORS;
195+
196+
const collector = ResultsCollector.create(page, projectUse);
197+
collector.withMockResponse({ webDetectionAutoRun: null, webEvent: null });
198+
await page.clock.install();
199+
await collector.load('/web-detection/index.html', config);
200+
const helper = new WebDetectionTestHelper(page, collector);
201+
202+
return { collector, helper };
203+
}
204+
118205
/**
119206
* Get webEvent notifications from outgoing messages.
120207
* @returns {Promise<Array<{type: string, data?: Record<string, unknown>}>>}
@@ -577,4 +664,47 @@ test.describe('WebDetection Feature', () => {
577664
expect(webEvents[0].type).toBe('adwall');
578665
});
579666
});
667+
668+
test.describe('captcha detection (per-provider events)', () => {
669+
/** @type {Array<[string, string]>} */
670+
const cases = [
671+
['captcha-recaptcha.html', 'captcha_recaptcha'],
672+
['captcha-hcaptcha.html', 'captcha_hcaptcha'],
673+
['captcha-turnstile.html', 'captcha_turnstile'],
674+
['captcha-cloudflare.html', 'captcha_cloudflare'],
675+
['captcha-other.html', 'captcha_other'],
676+
];
677+
678+
for (const [pageName, expectedType] of cases) {
679+
test(`fires exactly ${expectedType} for a visible ${pageName}`, async ({ page }, testInfo) => {
680+
const { helper } = await WebDetectionTestHelper.setupCaptchaTest(page, testInfo.project.use);
681+
await helper.navigateTo(`/web-detection/pages/${pageName}`);
682+
683+
await page.clock.fastForward(100);
684+
685+
const events = await helper.getWebEventNotifications();
686+
// Exactly one event, of the expected provider — proves per-provider firing and no
687+
// cross-counting (e.g. the Turnstile widget must not also fire captcha_cloudflare).
688+
expect(events.map((e) => e.type)).toEqual([expectedType]);
689+
});
690+
}
691+
692+
test('does not fire on a page without a captcha', async ({ page }, testInfo) => {
693+
const { helper } = await WebDetectionTestHelper.setupCaptchaTest(page, testInfo.project.use);
694+
await helper.navigateTo('/web-detection/pages/no-detection.html');
695+
696+
await page.clock.fastForward(100);
697+
698+
expect(await helper.getWebEventNotifications()).toEqual([]);
699+
});
700+
701+
test('does not fire for a hidden (display:none) captcha', async ({ page }, testInfo) => {
702+
const { helper } = await WebDetectionTestHelper.setupCaptchaTest(page, testInfo.project.use);
703+
await helper.navigateTo('/web-detection/pages/captcha-recaptcha-hidden.html');
704+
705+
await page.clock.fastForward(100);
706+
707+
expect(await helper.getWebEventNotifications()).toEqual([]);
708+
});
709+
});
580710
});

injected/unit-test/web-detection.js

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1193,5 +1193,125 @@ describe('WebDetection', () => {
11931193
expect(matchInDOM('<p>welcome</p>', condition)).toBe(false);
11941194
});
11951195
});
1196+
1197+
// Captcha vendor detectors. These mirror the per-provider `captcha` detectors in
1198+
// privacy-configuration/features/web-detection.json — keep the selectors in sync.
1199+
// The metric counts *visible* captcha challenges per navigation, so every detector
1200+
// uses visibility: 'visible'. Window-property signals (window.grecaptcha etc.) are
1201+
// intentionally NOT used: a loaded captcha library is not a visible challenge and
1202+
// would inflate the per-navigation ratio.
1203+
describe('captcha vendor detectors', () => {
1204+
/** @type {Record<string, import('../src/features/web-detection/parse.js').MatchCondition>} */
1205+
const captcha = {
1206+
recaptcha: {
1207+
element: {
1208+
selector: ['.g-recaptcha', "iframe[src*='recaptcha']", "iframe[title*='recaptcha' i]"],
1209+
visibility: 'visible',
1210+
},
1211+
},
1212+
hcaptcha: {
1213+
element: {
1214+
selector: ['.h-captcha', "iframe[src*='hcaptcha.com']", "iframe[title*='hcaptcha' i]"],
1215+
visibility: 'visible',
1216+
},
1217+
},
1218+
turnstile: {
1219+
element: {
1220+
// :not(#turnstile-wrapper) excludes the Cloudflare interstitial's own Turnstile wrapper
1221+
// (<div id="turnstile-wrapper" class="cf-turnstile">), so an interstitial counts as `cloudflare`, not `turnstile`.
1222+
selector: ['.cf-turnstile:not(#turnstile-wrapper)', '.cf-turnstile:not(#turnstile-wrapper) iframe'],
1223+
visibility: 'visible',
1224+
},
1225+
},
1226+
cloudflare: {
1227+
element: {
1228+
selector: [
1229+
'#challenge-form',
1230+
'#cf-wrapper',
1231+
'.cf-browser-verification',
1232+
'#challenge-running',
1233+
'#cf-challenge-running',
1234+
'#challenge-stage',
1235+
],
1236+
visibility: 'visible',
1237+
},
1238+
},
1239+
other: {
1240+
text: {
1241+
pattern: [
1242+
'press (and|&) hold to (confirm|verify)',
1243+
'slide( right)? to (verify|complete)',
1244+
'complete the security check to (access|continue)',
1245+
],
1246+
},
1247+
},
1248+
};
1249+
1250+
// Representative visible markup per vendor.
1251+
const fixtures = {
1252+
recaptcha:
1253+
'<div class="g-recaptcha"></div><iframe src="https://www.google.com/recaptcha/api2/anchor" title="reCAPTCHA"></iframe>',
1254+
hcaptcha:
1255+
'<div class="h-captcha"></div><iframe src="https://newassets.hcaptcha.com/captcha/v1/frame" title="hCaptcha"></iframe>',
1256+
turnstile:
1257+
'<div class="cf-turnstile"><iframe src="https://challenges.cloudflare.com/cdn-cgi/challenge-platform/turnstile"></iframe></div>',
1258+
// The current Cloudflare interstitial embeds Turnstile via <div id="turnstile-wrapper" class="cf-turnstile">,
1259+
// so it carries a .cf-turnstile element. It must match `cloudflare` (via #cf-wrapper/#challenge-*) but NOT
1260+
// `turnstile` — which is why the turnstile selector excludes #turnstile-wrapper.
1261+
cloudflare:
1262+
'<div id="cf-wrapper"><div id="challenge-running">Checking your browser before accessing the site.</div><div id="challenge-stage"><div id="turnstile-wrapper" class="cf-turnstile"><iframe src="https://challenges.cloudflare.com/cdn-cgi/challenge-platform/h/b/turnstile"></iframe></div></div></div>',
1263+
other: '<main><p>Please press and hold to confirm you are human.</p></main>',
1264+
};
1265+
1266+
const vendors = ['recaptcha', 'hcaptcha', 'turnstile', 'cloudflare', 'other'];
1267+
1268+
describe('matches its own visible fixture', () => {
1269+
for (const vendor of vendors) {
1270+
it(`${vendor}`, () => {
1271+
expect(matchInDOM(fixtures[vendor], captcha[vendor])).toBe(true);
1272+
});
1273+
}
1274+
});
1275+
1276+
describe('does not match other vendors (no double counting)', () => {
1277+
for (const vendor of vendors) {
1278+
for (const fixtureVendor of vendors) {
1279+
if (vendor === fixtureVendor) continue;
1280+
it(`${fixtureVendor} fixture does not match ${vendor}`, () => {
1281+
expect(matchInDOM(fixtures[fixtureVendor], captcha[vendor])).toBe(false);
1282+
});
1283+
}
1284+
}
1285+
});
1286+
1287+
describe('does not match when absent', () => {
1288+
const clean = '<main><h1>Welcome</h1><p>Just an ordinary article with nothing to verify.</p></main>';
1289+
for (const vendor of vendors) {
1290+
it(`${vendor}`, () => {
1291+
expect(matchInDOM(clean, captcha[vendor])).toBe(false);
1292+
});
1293+
}
1294+
});
1295+
1296+
describe('visibility gating (only visible challenges count)', () => {
1297+
it('hidden recaptcha (display:none) does not match', () => {
1298+
expect(matchInDOM('<div class="g-recaptcha" style="display: none"></div>', captcha.recaptcha)).toBe(false);
1299+
});
1300+
1301+
it('zero-size hcaptcha does not match', () => {
1302+
expect(matchInDOM('<div class="h-captcha"></div>', captcha.hcaptcha, { zeroSizeSelectors: ['.h-captcha'] })).toBe(
1303+
false,
1304+
);
1305+
});
1306+
1307+
it('hidden turnstile widget (display:none) does not match', () => {
1308+
expect(matchInDOM('<div class="cf-turnstile" style="display: none"></div>', captcha.turnstile)).toBe(false);
1309+
});
1310+
});
1311+
1312+
it('other does not fire on reCAPTCHA\'s "I\'m not a robot" label', () => {
1313+
expect(matchInDOM('<div class="g-recaptcha">I\'m not a robot</div>', captcha.other)).toBe(false);
1314+
});
1315+
});
11961316
});
11971317
});

0 commit comments

Comments
 (0)