@tsdevstack/react-bot-detection 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +314 -0
- package/dist/components/bot-protected-form.d.ts +42 -0
- package/dist/components/bot-protected-form.js +114 -0
- package/dist/components/bot-protected-form.test.d.ts +1 -0
- package/dist/components/bot-protected-form.test.js +366 -0
- package/dist/components/honeypot.d.ts +30 -0
- package/dist/components/honeypot.js +40 -0
- package/dist/components/honeypot.test.d.ts +1 -0
- package/dist/components/honeypot.test.js +186 -0
- package/dist/components/index.d.ts +4 -0
- package/dist/components/index.js +3 -0
- package/dist/hooks/index.d.ts +4 -0
- package/dist/hooks/index.js +3 -0
- package/dist/hooks/use-bot-detection.d.ts +31 -0
- package/dist/hooks/use-bot-detection.js +140 -0
- package/dist/hooks/use-bot-detection.test.d.ts +1 -0
- package/dist/hooks/use-bot-detection.test.js +384 -0
- package/dist/hooks/use-honeypot.d.ts +32 -0
- package/dist/hooks/use-honeypot.js +29 -0
- package/dist/hooks/use-honeypot.test.d.ts +1 -0
- package/dist/hooks/use-honeypot.test.js +78 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +3 -0
- package/dist/types/bot-detection.d.ts +33 -0
- package/dist/types/bot-detection.js +0 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +0 -0
- package/package.json +68 -0
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core";
|
|
3
|
+
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
|
4
|
+
import user_event from "@testing-library/user-event";
|
|
5
|
+
import { BotProtectedForm } from "./bot-protected-form.js";
|
|
6
|
+
import { useBotDetection } from "../hooks/use-bot-detection.js";
|
|
7
|
+
import { useHoneypot } from "../hooks/use-honeypot.js";
|
|
8
|
+
afterEach(()=>{
|
|
9
|
+
cleanup();
|
|
10
|
+
});
|
|
11
|
+
rs.mock('../hooks/use-bot-detection', ()=>({
|
|
12
|
+
useBotDetection: rs.fn(()=>({
|
|
13
|
+
botScore: 0,
|
|
14
|
+
detectionReasons: [],
|
|
15
|
+
isBot: false,
|
|
16
|
+
handleFieldFocus: rs.fn(),
|
|
17
|
+
handleFormSubmit: rs.fn(()=>({
|
|
18
|
+
score: 0,
|
|
19
|
+
reasons: []
|
|
20
|
+
})),
|
|
21
|
+
stats: {
|
|
22
|
+
mouseMovements: 10,
|
|
23
|
+
typingEvents: 20,
|
|
24
|
+
focusEvents: 5,
|
|
25
|
+
timeSpent: 5000
|
|
26
|
+
}
|
|
27
|
+
}))
|
|
28
|
+
}));
|
|
29
|
+
rs.mock('../hooks/use-honeypot', ()=>({
|
|
30
|
+
useHoneypot: rs.fn(()=>({
|
|
31
|
+
isBotDetected: false,
|
|
32
|
+
HoneypotComponent: ()=>/*#__PURE__*/ jsx("div", {
|
|
33
|
+
"data-testid": "honeypot"
|
|
34
|
+
})
|
|
35
|
+
}))
|
|
36
|
+
}));
|
|
37
|
+
const mockUseBotDetection = useBotDetection;
|
|
38
|
+
const mockUseHoneypot = useHoneypot;
|
|
39
|
+
describe('BotProtectedForm', ()=>{
|
|
40
|
+
const mockOnSubmit = rs.fn();
|
|
41
|
+
beforeEach(()=>{
|
|
42
|
+
rs.clearAllMocks();
|
|
43
|
+
mockOnSubmit.mockResolvedValue(void 0);
|
|
44
|
+
mockUseBotDetection.mockReturnValue({
|
|
45
|
+
botScore: 0,
|
|
46
|
+
detectionReasons: [],
|
|
47
|
+
isBot: false,
|
|
48
|
+
handleFieldFocus: rs.fn(),
|
|
49
|
+
handleFormSubmit: rs.fn(()=>({
|
|
50
|
+
score: 0,
|
|
51
|
+
reasons: []
|
|
52
|
+
})),
|
|
53
|
+
stats: {
|
|
54
|
+
mouseMovements: 10,
|
|
55
|
+
typingEvents: 20,
|
|
56
|
+
focusEvents: 5,
|
|
57
|
+
timeSpent: 5000
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
mockUseHoneypot.mockReturnValue({
|
|
61
|
+
isBotDetected: false,
|
|
62
|
+
HoneypotComponent: ()=>/*#__PURE__*/ jsx("div", {
|
|
63
|
+
"data-testid": "honeypot"
|
|
64
|
+
})
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
describe('Rendering', ()=>{
|
|
68
|
+
it('should render children and submit button', ()=>{
|
|
69
|
+
render(/*#__PURE__*/ jsx(BotProtectedForm, {
|
|
70
|
+
onSubmit: mockOnSubmit,
|
|
71
|
+
children: /*#__PURE__*/ jsx("input", {
|
|
72
|
+
name: "email",
|
|
73
|
+
"data-testid": "email"
|
|
74
|
+
})
|
|
75
|
+
}));
|
|
76
|
+
expect(screen.getByTestId('email')).not.toBeNull();
|
|
77
|
+
expect(screen.getByRole('button', {
|
|
78
|
+
name: 'Submit'
|
|
79
|
+
})).not.toBeNull();
|
|
80
|
+
});
|
|
81
|
+
it('should render honeypot component', ()=>{
|
|
82
|
+
render(/*#__PURE__*/ jsx(BotProtectedForm, {
|
|
83
|
+
onSubmit: mockOnSubmit,
|
|
84
|
+
children: /*#__PURE__*/ jsx("input", {
|
|
85
|
+
name: "email"
|
|
86
|
+
})
|
|
87
|
+
}));
|
|
88
|
+
expect(screen.getByTestId('honeypot')).not.toBeNull();
|
|
89
|
+
});
|
|
90
|
+
it('should use custom submit button text', ()=>{
|
|
91
|
+
render(/*#__PURE__*/ jsx(BotProtectedForm, {
|
|
92
|
+
onSubmit: mockOnSubmit,
|
|
93
|
+
submitButtonText: "Send",
|
|
94
|
+
children: /*#__PURE__*/ jsx("input", {
|
|
95
|
+
name: "email"
|
|
96
|
+
})
|
|
97
|
+
}));
|
|
98
|
+
expect(screen.getByRole('button', {
|
|
99
|
+
name: 'Send'
|
|
100
|
+
})).not.toBeNull();
|
|
101
|
+
});
|
|
102
|
+
it('should apply className to form', ()=>{
|
|
103
|
+
render(/*#__PURE__*/ jsx(BotProtectedForm, {
|
|
104
|
+
onSubmit: mockOnSubmit,
|
|
105
|
+
className: "my-form-class",
|
|
106
|
+
children: /*#__PURE__*/ jsx("input", {
|
|
107
|
+
name: "email"
|
|
108
|
+
})
|
|
109
|
+
}));
|
|
110
|
+
const form = screen.getByRole('button').closest('form');
|
|
111
|
+
expect(form?.classList.contains('my-form-class')).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
describe('Custom Button Component', ()=>{
|
|
115
|
+
it('should use custom ButtonComponent', ()=>{
|
|
116
|
+
const CustomButton = ({ children, disabled, type })=>/*#__PURE__*/ jsxs("button", {
|
|
117
|
+
type: type,
|
|
118
|
+
disabled: disabled,
|
|
119
|
+
"data-testid": "custom-button",
|
|
120
|
+
children: [
|
|
121
|
+
"Custom: ",
|
|
122
|
+
children
|
|
123
|
+
]
|
|
124
|
+
});
|
|
125
|
+
render(/*#__PURE__*/ jsx(BotProtectedForm, {
|
|
126
|
+
onSubmit: mockOnSubmit,
|
|
127
|
+
ButtonComponent: CustomButton,
|
|
128
|
+
children: /*#__PURE__*/ jsx("input", {
|
|
129
|
+
name: "email"
|
|
130
|
+
})
|
|
131
|
+
}));
|
|
132
|
+
expect(screen.getByTestId('custom-button')).not.toBeNull();
|
|
133
|
+
expect(screen.getByText('Custom: Submit')).not.toBeNull();
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
describe('Form Submission', ()=>{
|
|
137
|
+
it('should call onSubmit with FormData and bot detection result', async ()=>{
|
|
138
|
+
const user = user_event.setup();
|
|
139
|
+
render(/*#__PURE__*/ jsx(BotProtectedForm, {
|
|
140
|
+
onSubmit: mockOnSubmit,
|
|
141
|
+
children: /*#__PURE__*/ jsx("input", {
|
|
142
|
+
name: "email",
|
|
143
|
+
defaultValue: "test@example.com"
|
|
144
|
+
})
|
|
145
|
+
}));
|
|
146
|
+
await user.click(screen.getByRole('button', {
|
|
147
|
+
name: 'Submit'
|
|
148
|
+
}));
|
|
149
|
+
await waitFor(()=>{
|
|
150
|
+
expect(mockOnSubmit).toHaveBeenCalledTimes(1);
|
|
151
|
+
});
|
|
152
|
+
const [formData, botResult] = mockOnSubmit.mock.calls[0];
|
|
153
|
+
expect(formData).toBeInstanceOf(FormData);
|
|
154
|
+
expect(formData.get('email')).toBe('test@example.com');
|
|
155
|
+
expect(botResult).toMatchObject({
|
|
156
|
+
score: 0,
|
|
157
|
+
reasons: [],
|
|
158
|
+
isBot: false,
|
|
159
|
+
honeypotTriggered: false
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
it('should show loading text while submitting', async ()=>{
|
|
163
|
+
const user = user_event.setup();
|
|
164
|
+
let resolveSubmit;
|
|
165
|
+
mockOnSubmit.mockImplementation(()=>new Promise((resolve)=>{
|
|
166
|
+
resolveSubmit = resolve;
|
|
167
|
+
}));
|
|
168
|
+
render(/*#__PURE__*/ jsx(BotProtectedForm, {
|
|
169
|
+
onSubmit: mockOnSubmit,
|
|
170
|
+
submitButtonText: "Submit",
|
|
171
|
+
loadingButtonText: "Processing...",
|
|
172
|
+
children: /*#__PURE__*/ jsx("input", {
|
|
173
|
+
name: "email"
|
|
174
|
+
})
|
|
175
|
+
}));
|
|
176
|
+
await user.click(screen.getByRole('button', {
|
|
177
|
+
name: 'Submit'
|
|
178
|
+
}));
|
|
179
|
+
await waitFor(()=>{
|
|
180
|
+
expect(screen.getByRole('button', {
|
|
181
|
+
name: 'Processing...'
|
|
182
|
+
})).not.toBeNull();
|
|
183
|
+
});
|
|
184
|
+
resolveSubmit();
|
|
185
|
+
await waitFor(()=>{
|
|
186
|
+
expect(screen.getByRole('button', {
|
|
187
|
+
name: 'Submit'
|
|
188
|
+
})).not.toBeNull();
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
describe('Bot Detection Blocking', ()=>{
|
|
193
|
+
it('should not call onSubmit when behavioral bot detection triggers', async ()=>{
|
|
194
|
+
const user = user_event.setup();
|
|
195
|
+
mockUseBotDetection.mockReturnValue({
|
|
196
|
+
botScore: 100,
|
|
197
|
+
detectionReasons: [
|
|
198
|
+
'Suspicious behavior'
|
|
199
|
+
],
|
|
200
|
+
isBot: true,
|
|
201
|
+
handleFieldFocus: rs.fn(),
|
|
202
|
+
handleFormSubmit: rs.fn(()=>({
|
|
203
|
+
score: 100,
|
|
204
|
+
reasons: [
|
|
205
|
+
'Suspicious behavior'
|
|
206
|
+
]
|
|
207
|
+
})),
|
|
208
|
+
stats: {
|
|
209
|
+
mouseMovements: 0,
|
|
210
|
+
typingEvents: 0,
|
|
211
|
+
focusEvents: 0,
|
|
212
|
+
timeSpent: 100
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
render(/*#__PURE__*/ jsx(BotProtectedForm, {
|
|
216
|
+
onSubmit: mockOnSubmit,
|
|
217
|
+
children: /*#__PURE__*/ jsx("input", {
|
|
218
|
+
name: "email"
|
|
219
|
+
})
|
|
220
|
+
}));
|
|
221
|
+
await user.click(screen.getByRole('button', {
|
|
222
|
+
name: 'Submit'
|
|
223
|
+
}));
|
|
224
|
+
expect(mockOnSubmit).not.toHaveBeenCalled();
|
|
225
|
+
});
|
|
226
|
+
it('should not call onSubmit when honeypot is triggered', async ()=>{
|
|
227
|
+
const user = user_event.setup();
|
|
228
|
+
mockUseHoneypot.mockReturnValue({
|
|
229
|
+
isBotDetected: true,
|
|
230
|
+
HoneypotComponent: ()=>/*#__PURE__*/ jsx("div", {
|
|
231
|
+
"data-testid": "honeypot"
|
|
232
|
+
})
|
|
233
|
+
});
|
|
234
|
+
render(/*#__PURE__*/ jsx(BotProtectedForm, {
|
|
235
|
+
onSubmit: mockOnSubmit,
|
|
236
|
+
children: /*#__PURE__*/ jsx("input", {
|
|
237
|
+
name: "email"
|
|
238
|
+
})
|
|
239
|
+
}));
|
|
240
|
+
await user.click(screen.getByRole('button', {
|
|
241
|
+
name: 'Submit'
|
|
242
|
+
}));
|
|
243
|
+
expect(mockOnSubmit).not.toHaveBeenCalled();
|
|
244
|
+
});
|
|
245
|
+
it('should not disable submit button based on stale bot detection state', ()=>{
|
|
246
|
+
mockUseBotDetection.mockReturnValue({
|
|
247
|
+
botScore: 100,
|
|
248
|
+
detectionReasons: [
|
|
249
|
+
'Suspicious behavior'
|
|
250
|
+
],
|
|
251
|
+
isBot: true,
|
|
252
|
+
handleFieldFocus: rs.fn(),
|
|
253
|
+
handleFormSubmit: rs.fn(),
|
|
254
|
+
stats: {
|
|
255
|
+
mouseMovements: 0,
|
|
256
|
+
typingEvents: 0,
|
|
257
|
+
focusEvents: 0,
|
|
258
|
+
timeSpent: 100
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
render(/*#__PURE__*/ jsx(BotProtectedForm, {
|
|
262
|
+
onSubmit: mockOnSubmit,
|
|
263
|
+
children: /*#__PURE__*/ jsx("input", {
|
|
264
|
+
name: "email"
|
|
265
|
+
})
|
|
266
|
+
}));
|
|
267
|
+
const button = screen.getByRole('button', {
|
|
268
|
+
name: 'Submit'
|
|
269
|
+
});
|
|
270
|
+
expect(button.disabled).toBe(false);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
describe('onBotDetected callback', ()=>{
|
|
274
|
+
it('should call onBotDetected when form detects a bot during submission', async ()=>{
|
|
275
|
+
const user = user_event.setup();
|
|
276
|
+
const mockOnBotDetected = rs.fn();
|
|
277
|
+
mockUseBotDetection.mockReturnValue({
|
|
278
|
+
botScore: 0,
|
|
279
|
+
detectionReasons: [],
|
|
280
|
+
isBot: false,
|
|
281
|
+
handleFieldFocus: rs.fn(),
|
|
282
|
+
handleFormSubmit: rs.fn(()=>({
|
|
283
|
+
score: 80,
|
|
284
|
+
reasons: [
|
|
285
|
+
'Too fast submission'
|
|
286
|
+
]
|
|
287
|
+
})),
|
|
288
|
+
stats: {
|
|
289
|
+
mouseMovements: 0,
|
|
290
|
+
typingEvents: 0,
|
|
291
|
+
focusEvents: 0,
|
|
292
|
+
timeSpent: 100
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
render(/*#__PURE__*/ jsx(BotProtectedForm, {
|
|
296
|
+
onSubmit: mockOnSubmit,
|
|
297
|
+
onBotDetected: mockOnBotDetected,
|
|
298
|
+
children: /*#__PURE__*/ jsx("input", {
|
|
299
|
+
name: "email"
|
|
300
|
+
})
|
|
301
|
+
}));
|
|
302
|
+
await user.click(screen.getByRole('button', {
|
|
303
|
+
name: 'Submit'
|
|
304
|
+
}));
|
|
305
|
+
await waitFor(()=>{
|
|
306
|
+
expect(mockOnBotDetected).toHaveBeenCalledWith(expect.objectContaining({
|
|
307
|
+
score: 80,
|
|
308
|
+
isBot: true,
|
|
309
|
+
reasons: expect.arrayContaining([
|
|
310
|
+
'Too fast submission'
|
|
311
|
+
])
|
|
312
|
+
}));
|
|
313
|
+
});
|
|
314
|
+
expect(mockOnSubmit).not.toHaveBeenCalled();
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
describe('Debug Panel', ()=>{
|
|
318
|
+
it('should not render debug panel by default', ()=>{
|
|
319
|
+
render(/*#__PURE__*/ jsx(BotProtectedForm, {
|
|
320
|
+
onSubmit: mockOnSubmit,
|
|
321
|
+
children: /*#__PURE__*/ jsx("input", {
|
|
322
|
+
name: "email"
|
|
323
|
+
})
|
|
324
|
+
}));
|
|
325
|
+
expect(screen.queryByText('Bot Detection Debug')).toBeNull();
|
|
326
|
+
});
|
|
327
|
+
it('should render debug panel when showDebugPanel is true', async ()=>{
|
|
328
|
+
render(/*#__PURE__*/ jsx(BotProtectedForm, {
|
|
329
|
+
onSubmit: mockOnSubmit,
|
|
330
|
+
showDebugPanel: true,
|
|
331
|
+
children: /*#__PURE__*/ jsx("input", {
|
|
332
|
+
name: "email"
|
|
333
|
+
})
|
|
334
|
+
}));
|
|
335
|
+
await waitFor(()=>{
|
|
336
|
+
expect(screen.getByText('Bot Detection Debug')).not.toBeNull();
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
describe('Error Handling', ()=>{
|
|
341
|
+
it('should handle submission errors gracefully', async ()=>{
|
|
342
|
+
const user = user_event.setup();
|
|
343
|
+
const consoleError = rs.spyOn(console, 'error').mockImplementation(()=>{});
|
|
344
|
+
mockOnSubmit.mockRejectedValue(new Error('Network error'));
|
|
345
|
+
render(/*#__PURE__*/ jsx(BotProtectedForm, {
|
|
346
|
+
onSubmit: mockOnSubmit,
|
|
347
|
+
children: /*#__PURE__*/ jsx("input", {
|
|
348
|
+
name: "email"
|
|
349
|
+
})
|
|
350
|
+
}));
|
|
351
|
+
await user.click(screen.getByRole('button', {
|
|
352
|
+
name: 'Submit'
|
|
353
|
+
}));
|
|
354
|
+
await waitFor(()=>{
|
|
355
|
+
expect(consoleError).toHaveBeenCalledWith('Form submission error:', expect.any(Error));
|
|
356
|
+
});
|
|
357
|
+
await waitFor(()=>{
|
|
358
|
+
const button = screen.getByRole('button', {
|
|
359
|
+
name: 'Submit'
|
|
360
|
+
});
|
|
361
|
+
expect(button.disabled).toBe(false);
|
|
362
|
+
});
|
|
363
|
+
consoleError.mockRestore();
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Default honeypot field names - attractive to bots but NOT to browser autofill.
|
|
4
|
+
*
|
|
5
|
+
* We use prefixed names that:
|
|
6
|
+
* 1. Look like real fields to bots scanning for input names
|
|
7
|
+
* 2. Don't match browser autofill heuristics (email, phone, url, address patterns)
|
|
8
|
+
*
|
|
9
|
+
* The prefix "hp_" makes them unique enough to avoid autofill while
|
|
10
|
+
* the suffixes still look like fields bots want to fill.
|
|
11
|
+
*/
|
|
12
|
+
export declare const DEFAULT_HONEYPOT_FIELDS: readonly ["hp_contact_info", "hp_website_url", "hp_fax_number", "hp_company_name"];
|
|
13
|
+
export interface HoneypotProps {
|
|
14
|
+
/** Callback when bot is detected (any honeypot field was filled) */
|
|
15
|
+
onBotDetected: () => void;
|
|
16
|
+
/** Custom field names (defaults to email_confirm, website, url, phone_number) */
|
|
17
|
+
fieldNames?: string[];
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Hidden honeypot fields that bots typically fill out.
|
|
21
|
+
*
|
|
22
|
+
* These fields are invisible to humans but visible to bots
|
|
23
|
+
* that parse the DOM looking for form fields to fill.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```tsx
|
|
27
|
+
* <Honeypot onBotDetected={() => setIsBot(true)} />
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export declare function Honeypot({ onBotDetected, fieldNames, }: HoneypotProps): React.ReactElement;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx } from "react/jsx-runtime";
|
|
3
|
+
import { useCallback, useRef } from "react";
|
|
4
|
+
const DEFAULT_HONEYPOT_FIELDS = [
|
|
5
|
+
'hp_contact_info',
|
|
6
|
+
'hp_website_url',
|
|
7
|
+
'hp_fax_number',
|
|
8
|
+
'hp_company_name'
|
|
9
|
+
];
|
|
10
|
+
function Honeypot({ onBotDetected, fieldNames = DEFAULT_HONEYPOT_FIELDS }) {
|
|
11
|
+
const hasTriggered = useRef(false);
|
|
12
|
+
const handleFieldChange = useCallback((e)=>{
|
|
13
|
+
if ('' !== e.target.value.trim() && !hasTriggered.current) {
|
|
14
|
+
hasTriggered.current = true;
|
|
15
|
+
onBotDetected();
|
|
16
|
+
}
|
|
17
|
+
}, [
|
|
18
|
+
onBotDetected
|
|
19
|
+
]);
|
|
20
|
+
return /*#__PURE__*/ jsx("div", {
|
|
21
|
+
style: {
|
|
22
|
+
position: 'absolute',
|
|
23
|
+
left: '-9999px',
|
|
24
|
+
opacity: 0,
|
|
25
|
+
pointerEvents: 'none'
|
|
26
|
+
},
|
|
27
|
+
"aria-hidden": "true",
|
|
28
|
+
children: fieldNames.map((fieldName)=>/*#__PURE__*/ jsx("input", {
|
|
29
|
+
type: "text",
|
|
30
|
+
name: fieldName,
|
|
31
|
+
onChange: handleFieldChange,
|
|
32
|
+
tabIndex: -1,
|
|
33
|
+
autoComplete: "new-password",
|
|
34
|
+
"aria-hidden": "true",
|
|
35
|
+
"data-lpignore": "true",
|
|
36
|
+
"data-1p-ignore": "true"
|
|
37
|
+
}, fieldName))
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
export { DEFAULT_HONEYPOT_FIELDS, Honeypot };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx } from "react/jsx-runtime";
|
|
3
|
+
import { afterEach, describe, expect, it, rs } from "@rstest/core";
|
|
4
|
+
import { act, cleanup, fireEvent, render } from "@testing-library/react";
|
|
5
|
+
import { DEFAULT_HONEYPOT_FIELDS, Honeypot } from "./honeypot.js";
|
|
6
|
+
afterEach(()=>{
|
|
7
|
+
cleanup();
|
|
8
|
+
});
|
|
9
|
+
describe('Honeypot', ()=>{
|
|
10
|
+
describe('rendering', ()=>{
|
|
11
|
+
it('should render without crashing', ()=>{
|
|
12
|
+
const onBotDetected = rs.fn();
|
|
13
|
+
const { container } = render(/*#__PURE__*/ jsx(Honeypot, {
|
|
14
|
+
onBotDetected: onBotDetected
|
|
15
|
+
}));
|
|
16
|
+
expect(container.firstChild).not.toBeNull();
|
|
17
|
+
});
|
|
18
|
+
it('should render hidden fields', ()=>{
|
|
19
|
+
const onBotDetected = rs.fn();
|
|
20
|
+
render(/*#__PURE__*/ jsx(Honeypot, {
|
|
21
|
+
onBotDetected: onBotDetected
|
|
22
|
+
}));
|
|
23
|
+
const hiddenWrapper = document.querySelector('[aria-hidden="true"]');
|
|
24
|
+
expect(hiddenWrapper).not.toBeNull();
|
|
25
|
+
});
|
|
26
|
+
it('should position fields off-screen', ()=>{
|
|
27
|
+
const onBotDetected = rs.fn();
|
|
28
|
+
render(/*#__PURE__*/ jsx(Honeypot, {
|
|
29
|
+
onBotDetected: onBotDetected
|
|
30
|
+
}));
|
|
31
|
+
const hiddenWrapper = document.querySelector('[aria-hidden="true"]');
|
|
32
|
+
expect(hiddenWrapper.style.position).toBe('absolute');
|
|
33
|
+
expect(hiddenWrapper.style.left).toBe('-9999px');
|
|
34
|
+
});
|
|
35
|
+
it('should render all default honeypot fields', ()=>{
|
|
36
|
+
const onBotDetected = rs.fn();
|
|
37
|
+
render(/*#__PURE__*/ jsx(Honeypot, {
|
|
38
|
+
onBotDetected: onBotDetected
|
|
39
|
+
}));
|
|
40
|
+
for (const fieldName of DEFAULT_HONEYPOT_FIELDS)expect(document.querySelector(`[name="${fieldName}"]`)).not.toBeNull();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
describe('bot detection', ()=>{
|
|
44
|
+
it('should not call onBotDetected when fields are empty', ()=>{
|
|
45
|
+
const onBotDetected = rs.fn();
|
|
46
|
+
render(/*#__PURE__*/ jsx(Honeypot, {
|
|
47
|
+
onBotDetected: onBotDetected
|
|
48
|
+
}));
|
|
49
|
+
expect(onBotDetected).not.toHaveBeenCalled();
|
|
50
|
+
});
|
|
51
|
+
it('should call onBotDetected when any field is filled', async ()=>{
|
|
52
|
+
const onBotDetected = rs.fn();
|
|
53
|
+
render(/*#__PURE__*/ jsx(Honeypot, {
|
|
54
|
+
onBotDetected: onBotDetected
|
|
55
|
+
}));
|
|
56
|
+
const websiteField = document.querySelector('[name="hp_website_url"]');
|
|
57
|
+
await act(async ()=>{
|
|
58
|
+
fireEvent.change(websiteField, {
|
|
59
|
+
target: {
|
|
60
|
+
value: 'spam-value'
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
expect(onBotDetected).toHaveBeenCalledTimes(1);
|
|
65
|
+
});
|
|
66
|
+
it('should trigger on first field filled', async ()=>{
|
|
67
|
+
const onBotDetected = rs.fn();
|
|
68
|
+
render(/*#__PURE__*/ jsx(Honeypot, {
|
|
69
|
+
onBotDetected: onBotDetected
|
|
70
|
+
}));
|
|
71
|
+
const contactField = document.querySelector('[name="hp_contact_info"]');
|
|
72
|
+
await act(async ()=>{
|
|
73
|
+
fireEvent.change(contactField, {
|
|
74
|
+
target: {
|
|
75
|
+
value: 'bot@spam.com'
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
expect(onBotDetected).toHaveBeenCalledTimes(1);
|
|
80
|
+
});
|
|
81
|
+
it('should only trigger once even if multiple fields filled', async ()=>{
|
|
82
|
+
const onBotDetected = rs.fn();
|
|
83
|
+
render(/*#__PURE__*/ jsx(Honeypot, {
|
|
84
|
+
onBotDetected: onBotDetected
|
|
85
|
+
}));
|
|
86
|
+
const websiteField = document.querySelector('[name="hp_website_url"]');
|
|
87
|
+
const faxField = document.querySelector('[name="hp_fax_number"]');
|
|
88
|
+
await act(async ()=>{
|
|
89
|
+
fireEvent.change(websiteField, {
|
|
90
|
+
target: {
|
|
91
|
+
value: 'spam1'
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
fireEvent.change(faxField, {
|
|
95
|
+
target: {
|
|
96
|
+
value: 'spam2'
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
expect(onBotDetected).toHaveBeenCalledTimes(1);
|
|
101
|
+
});
|
|
102
|
+
it('should not trigger on whitespace-only input', async ()=>{
|
|
103
|
+
const onBotDetected = rs.fn();
|
|
104
|
+
render(/*#__PURE__*/ jsx(Honeypot, {
|
|
105
|
+
onBotDetected: onBotDetected
|
|
106
|
+
}));
|
|
107
|
+
const websiteField = document.querySelector('[name="hp_website_url"]');
|
|
108
|
+
await act(async ()=>{
|
|
109
|
+
fireEvent.change(websiteField, {
|
|
110
|
+
target: {
|
|
111
|
+
value: ' '
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
expect(onBotDetected).not.toHaveBeenCalled();
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
describe('accessibility', ()=>{
|
|
119
|
+
it('should have aria-hidden attribute on wrapper', ()=>{
|
|
120
|
+
const onBotDetected = rs.fn();
|
|
121
|
+
render(/*#__PURE__*/ jsx(Honeypot, {
|
|
122
|
+
onBotDetected: onBotDetected
|
|
123
|
+
}));
|
|
124
|
+
const hiddenWrapper = document.querySelector('[aria-hidden="true"]');
|
|
125
|
+
expect(hiddenWrapper).not.toBeNull();
|
|
126
|
+
});
|
|
127
|
+
it('should have tabindex -1 on fields', ()=>{
|
|
128
|
+
const onBotDetected = rs.fn();
|
|
129
|
+
render(/*#__PURE__*/ jsx(Honeypot, {
|
|
130
|
+
onBotDetected: onBotDetected
|
|
131
|
+
}));
|
|
132
|
+
const field = document.querySelector('[name="hp_contact_info"]');
|
|
133
|
+
expect(field.tabIndex).toBe(-1);
|
|
134
|
+
});
|
|
135
|
+
it('should have autocomplete new-password on fields to prevent autofill', ()=>{
|
|
136
|
+
const onBotDetected = rs.fn();
|
|
137
|
+
render(/*#__PURE__*/ jsx(Honeypot, {
|
|
138
|
+
onBotDetected: onBotDetected
|
|
139
|
+
}));
|
|
140
|
+
const field = document.querySelector('[name="hp_contact_info"]');
|
|
141
|
+
expect(field.getAttribute('autocomplete')).toBe('new-password');
|
|
142
|
+
});
|
|
143
|
+
it('should have password manager ignore attributes', ()=>{
|
|
144
|
+
const onBotDetected = rs.fn();
|
|
145
|
+
render(/*#__PURE__*/ jsx(Honeypot, {
|
|
146
|
+
onBotDetected: onBotDetected
|
|
147
|
+
}));
|
|
148
|
+
const field = document.querySelector('[name="hp_contact_info"]');
|
|
149
|
+
expect(field.getAttribute('data-lpignore')).toBe('true');
|
|
150
|
+
expect(field.getAttribute('data-1p-ignore')).toBe('true');
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
describe('custom field names', ()=>{
|
|
154
|
+
it('should use custom field names', ()=>{
|
|
155
|
+
const onBotDetected = rs.fn();
|
|
156
|
+
render(/*#__PURE__*/ jsx(Honeypot, {
|
|
157
|
+
onBotDetected: onBotDetected,
|
|
158
|
+
fieldNames: [
|
|
159
|
+
'custom_trap',
|
|
160
|
+
'fake_phone'
|
|
161
|
+
]
|
|
162
|
+
}));
|
|
163
|
+
expect(document.querySelector('[name="custom_trap"]')).not.toBeNull();
|
|
164
|
+
expect(document.querySelector('[name="fake_phone"]')).not.toBeNull();
|
|
165
|
+
expect(document.querySelector('[name="email_confirm"]')).toBeNull();
|
|
166
|
+
});
|
|
167
|
+
it('should trigger detection on custom fields', async ()=>{
|
|
168
|
+
const onBotDetected = rs.fn();
|
|
169
|
+
render(/*#__PURE__*/ jsx(Honeypot, {
|
|
170
|
+
onBotDetected: onBotDetected,
|
|
171
|
+
fieldNames: [
|
|
172
|
+
'my_honeypot'
|
|
173
|
+
]
|
|
174
|
+
}));
|
|
175
|
+
const customField = document.querySelector('[name="my_honeypot"]');
|
|
176
|
+
await act(async ()=>{
|
|
177
|
+
fireEvent.change(customField, {
|
|
178
|
+
target: {
|
|
179
|
+
value: 'bot-filled'
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
expect(onBotDetected).toHaveBeenCalled();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { BotDetectionResult, BotDetectionStats } from '../types';
|
|
2
|
+
export interface UseBotDetectionReturn {
|
|
3
|
+
botScore: number;
|
|
4
|
+
detectionReasons: string[];
|
|
5
|
+
isBot: boolean;
|
|
6
|
+
analyzeBehavior: () => BotDetectionResult;
|
|
7
|
+
handleFieldFocus: () => void;
|
|
8
|
+
handleFormSubmit: () => BotDetectionResult;
|
|
9
|
+
stats: BotDetectionStats;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Hook for behavioral bot detection.
|
|
13
|
+
*
|
|
14
|
+
* Tracks mouse movements, typing patterns, and form interactions
|
|
15
|
+
* to detect automated form submissions.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```tsx
|
|
19
|
+
* const { handleFormSubmit, isBot } = useBotDetection();
|
|
20
|
+
*
|
|
21
|
+
* const onSubmit = () => {
|
|
22
|
+
* const result = handleFormSubmit();
|
|
23
|
+
* if (result.isBot) {
|
|
24
|
+
* console.log('Bot detected:', result.reasons);
|
|
25
|
+
* return;
|
|
26
|
+
* }
|
|
27
|
+
* // Proceed with submission
|
|
28
|
+
* };
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export declare function useBotDetection(): UseBotDetectionReturn;
|