diff --git a/client/fcaptcha.js b/client/fcaptcha.js index 1912cc8..9e2ca65 100644 --- a/client/fcaptcha.js +++ b/client/fcaptcha.js @@ -532,7 +532,7 @@ const accelerations = this.mouseAccelerations; if (positions.length < 10) { - return this._getEmptyAnalysis(); + return this._getEmptyAnalysis(positions.length); } // Velocity statistics @@ -717,7 +717,7 @@ analyzeClick(clickX, clickY, targetRect) { const positions = this.mousePositions; if (positions.length < 5) { - return this._getEmptyClickAnalysis(); + return this._getEmptyClickAnalysis(positions.length); } // Approach trajectory (last 20 points) @@ -885,9 +885,12 @@ return arr.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / arr.length; } - _getEmptyAnalysis() { + _getEmptyAnalysis(totalPoints = 0) { return { - totalPoints: 0, trajectoryLength: 0, avgVelocity: 0, velocityVariance: 0, + // A short trace is insufficient for stable trajectory statistics, but + // its sample count is still an observed fact. Reporting zero here made + // 1-9 genuine mouse moves indistinguishable from no mouse use at all. + totalPoints, trajectoryLength: 0, avgVelocity: 0, velocityVariance: 0, avgAcceleration: 0, accelerationChanges: 0, microTremorScore: 0.5, straightLineRatio: 0, microMovements: 0, directionChanges: 0, eventDeltas: [], eventDeltaVariance: 0, mouseEventRate: 0, @@ -902,10 +905,10 @@ }; } - _getEmptyClickAnalysis() { + _getEmptyClickAnalysis(approachPoints = 0) { return { clickPrecision: 0, explorationRatio: 0, overshootCorrections: 0, - hoverTime: 0, approachDirectness: 1, approachPoints: 0 + hoverTime: 0, approachDirectness: 1, approachPoints }; } } diff --git a/test/browser/tests/sparse-mouse.spec.ts b/test/browser/tests/sparse-mouse.spec.ts new file mode 100644 index 0000000..a99f5be --- /dev/null +++ b/test/browser/tests/sparse-mouse.spec.ts @@ -0,0 +1,54 @@ +import { test, expect, Page } from '@playwright/test'; + +test.setTimeout(60_000); + +async function loadWidget(page: Page) { + await page.route('http://localhost:3000/__sparse_mouse__', (route) => + route.fulfill({ + status: 200, + contentType: 'text/html; charset=utf-8', + body: `
+ + + + `, + }) + ); + + await page.goto('http://localhost:3000/__sparse_mouse__'); + await page.waitForSelector('.fcaptcha-checkbox'); +} + +test('preserves observed counts for a sub-threshold mouse trace', async ({ page }) => { + await loadWidget(page); + + const verifyRequest = page.waitForRequest((request) => + request.method() === 'POST' && request.url().endsWith('/api/verify') + ); + + await page.evaluate(() => { + // These synthetic events test serialization only. Their verdict says + // nothing about human false positives: Playwright should be detected. + for (let i = 0; i < 9; i++) { + document.dispatchEvent(new MouseEvent('mousemove', { + bubbles: true, + clientX: 40 + i * 3, + clientY: 80 + i * 2, + })); + } + + const checkbox = document.querySelector('.fcaptcha-checkbox'); + checkbox?.dispatchEvent(new MouseEvent('click', { + bubbles: true, + clientX: 64, + clientY: 96, + })); + }); + + const body = (await verifyRequest).postDataJSON(); + expect(body.signals.behavioral.totalPoints).toBe(9); + expect(body.signals.behavioral.approachPoints).toBe(9); +});