@l.x/sessions 1.0.3 → 1.0.5
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/.depcheckrc +20 -0
- package/.eslintrc.js +21 -0
- package/LICENSE +122 -0
- package/README.md +1 -0
- package/env.d.ts +12 -0
- package/package.json +49 -1
- package/project.json +36 -0
- package/src/challenge-solvers/createChallengeSolverService.ts +64 -0
- package/src/challenge-solvers/createHashcashMockSolver.ts +39 -0
- package/src/challenge-solvers/createHashcashSolver.test.ts +385 -0
- package/src/challenge-solvers/createHashcashSolver.ts +270 -0
- package/src/challenge-solvers/createNoneMockSolver.ts +11 -0
- package/src/challenge-solvers/createTurnstileMockSolver.ts +30 -0
- package/src/challenge-solvers/createTurnstileSolver.ts +357 -0
- package/src/challenge-solvers/hashcash/core.native.ts +34 -0
- package/src/challenge-solvers/hashcash/core.test.ts +314 -0
- package/src/challenge-solvers/hashcash/core.ts +35 -0
- package/src/challenge-solvers/hashcash/core.web.ts +123 -0
- package/src/challenge-solvers/hashcash/createWorkerHashcashSolver.test.ts +195 -0
- package/src/challenge-solvers/hashcash/createWorkerHashcashSolver.ts +120 -0
- package/src/challenge-solvers/hashcash/shared.ts +70 -0
- package/src/challenge-solvers/hashcash/worker/createHashcashMultiWorkerChannel.native.ts +22 -0
- package/src/challenge-solvers/hashcash/worker/createHashcashMultiWorkerChannel.ts +22 -0
- package/src/challenge-solvers/hashcash/worker/createHashcashMultiWorkerChannel.web.ts +212 -0
- package/src/challenge-solvers/hashcash/worker/createHashcashWorkerChannel.native.ts +16 -0
- package/src/challenge-solvers/hashcash/worker/createHashcashWorkerChannel.ts +16 -0
- package/src/challenge-solvers/hashcash/worker/createHashcashWorkerChannel.web.ts +97 -0
- package/src/challenge-solvers/hashcash/worker/hashcash.worker.ts +91 -0
- package/src/challenge-solvers/hashcash/worker/types.ts +69 -0
- package/src/challenge-solvers/turnstileErrors.ts +49 -0
- package/src/challenge-solvers/turnstileScriptLoader.ts +325 -0
- package/src/challenge-solvers/turnstileSolver.integration.test.ts +331 -0
- package/src/challenge-solvers/types.ts +55 -0
- package/src/challengeFlow.integration.test.ts +627 -0
- package/src/device-id/createDeviceIdService.ts +19 -0
- package/src/device-id/types.ts +11 -0
- package/src/index.ts +139 -0
- package/src/lx-identifier/createLXIdentifierService.ts +1 -0
- package/src/lx-identifier/createUniswapIdentifierService.ts +19 -0
- package/src/lx-identifier/lxIdentifierQuery.ts +1 -0
- package/src/lx-identifier/types.ts +11 -0
- package/src/lx-identifier/uniswapIdentifierQuery.ts +20 -0
- package/src/oauth-service/createOAuthService.ts +125 -0
- package/src/oauth-service/types.ts +104 -0
- package/src/performance/createNoopPerformanceTracker.ts +12 -0
- package/src/performance/createPerformanceTracker.ts +43 -0
- package/src/performance/index.ts +7 -0
- package/src/performance/types.ts +11 -0
- package/src/session-initialization/createSessionInitializationService.test.ts +557 -0
- package/src/session-initialization/createSessionInitializationService.ts +184 -0
- package/src/session-initialization/sessionErrors.ts +32 -0
- package/src/session-repository/createSessionClient.ts +10 -0
- package/src/session-repository/createSessionRepository.test.ts +313 -0
- package/src/session-repository/createSessionRepository.ts +242 -0
- package/src/session-repository/errors.ts +22 -0
- package/src/session-repository/types.ts +289 -0
- package/src/session-service/createNoopSessionService.ts +24 -0
- package/src/session-service/createSessionService.test.ts +388 -0
- package/src/session-service/createSessionService.ts +61 -0
- package/src/session-service/types.ts +59 -0
- package/src/session-storage/createSessionStorage.ts +28 -0
- package/src/session-storage/types.ts +15 -0
- package/src/session.integration.test.ts +516 -0
- package/src/sessionLifecycle.integration.test.ts +264 -0
- package/src/test-utils/createLocalCookieTransport.ts +52 -0
- package/src/test-utils/createLocalHeaderTransport.ts +45 -0
- package/src/test-utils/mocks.ts +122 -0
- package/src/test-utils.ts +200 -0
- package/src/uniswap-identifier/createUniswapIdentifierService.ts +19 -0
- package/src/uniswap-identifier/types.ts +11 -0
- package/src/uniswap-identifier/uniswapIdentifierQuery.ts +20 -0
- package/tsconfig.json +26 -0
- package/tsconfig.lint.json +8 -0
- package/tsconfig.spec.json +8 -0
- package/vitest.config.ts +20 -0
- package/vitest.integration.config.ts +14 -0
- package/index.d.ts +0 -1
- package/index.js +0 -1
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { TurnstileScriptLoadError } from '@l.x/sessions/src/challenge-solvers/turnstileErrors'
|
|
2
|
+
import type { TurnstileScriptOptions } from '@l.x/sessions/src/challenge-solvers/types'
|
|
3
|
+
|
|
4
|
+
type TurnstileState = 'unloaded' | 'loading' | 'ready'
|
|
5
|
+
|
|
6
|
+
interface TurnstileLoadPromise {
|
|
7
|
+
resolve: (value?: void | PromiseLike<void>) => void
|
|
8
|
+
reject: (reason?: unknown) => void
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Global state machine for Turnstile script loading
|
|
13
|
+
* Singleton pattern ensures shared state across solver instances
|
|
14
|
+
*/
|
|
15
|
+
class TurnstileScriptState {
|
|
16
|
+
private state: TurnstileState = 'unloaded'
|
|
17
|
+
private loadPromise: Promise<void>
|
|
18
|
+
private loadResolvers: TurnstileLoadPromise | null = null
|
|
19
|
+
|
|
20
|
+
constructor() {
|
|
21
|
+
this.loadPromise = new Promise((resolve, reject) => {
|
|
22
|
+
this.loadResolvers = { resolve, reject }
|
|
23
|
+
// If already ready, resolve immediately
|
|
24
|
+
if (this.state === 'ready' && window.turnstile) {
|
|
25
|
+
resolve(undefined)
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
getState(): TurnstileState {
|
|
31
|
+
return this.state
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
setState(newState: TurnstileState): void {
|
|
35
|
+
this.state = newState
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
getLoadPromise(): Promise<void> {
|
|
39
|
+
return this.loadPromise
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
resolveLoad(): void {
|
|
43
|
+
if (this.loadResolvers) {
|
|
44
|
+
this.loadResolvers.resolve(undefined)
|
|
45
|
+
this.loadResolvers = null
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
rejectLoad(reason: unknown): void {
|
|
50
|
+
if (this.loadResolvers) {
|
|
51
|
+
this.loadResolvers.reject(reason)
|
|
52
|
+
this.loadResolvers = null
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Reset state for testing purposes
|
|
58
|
+
*/
|
|
59
|
+
reset(): void {
|
|
60
|
+
this.state = 'unloaded'
|
|
61
|
+
this.loadPromise = new Promise((resolve, reject) => {
|
|
62
|
+
this.loadResolvers = { resolve, reject }
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Singleton instance
|
|
68
|
+
const turnstileState = new TurnstileScriptState()
|
|
69
|
+
|
|
70
|
+
const DEFAULT_SCRIPT_ID = 'cf-turnstile-script'
|
|
71
|
+
const DEFAULT_ONLOAD_NAME = 'onloadTurnstileCallback'
|
|
72
|
+
const TURNSTILE_SCRIPT_URL = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Observes script loading using MutationObserver
|
|
76
|
+
* More reliable than polling for detecting when script is loaded
|
|
77
|
+
*/
|
|
78
|
+
function observeScriptLoad(scriptId: string, timeoutMs = 10000): Promise<void> {
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
// Check if script already exists and is loaded
|
|
81
|
+
const existingScript = document.getElementById(scriptId) as HTMLScriptElement | null
|
|
82
|
+
if (existingScript && window.turnstile) {
|
|
83
|
+
resolve(undefined)
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// If script exists but not loaded yet, set up load listener
|
|
88
|
+
if (existingScript && !window.turnstile) {
|
|
89
|
+
// Track intervals so we can clean them up from any code path
|
|
90
|
+
let loadCheckIntervalId: ReturnType<typeof setInterval> | null = null
|
|
91
|
+
let raceCheckIntervalId: ReturnType<typeof setInterval> | null = null
|
|
92
|
+
|
|
93
|
+
const cleanup = (): void => {
|
|
94
|
+
if (loadCheckIntervalId) {
|
|
95
|
+
clearInterval(loadCheckIntervalId)
|
|
96
|
+
}
|
|
97
|
+
if (raceCheckIntervalId) {
|
|
98
|
+
clearInterval(raceCheckIntervalId)
|
|
99
|
+
}
|
|
100
|
+
existingScript.removeEventListener('load', onScriptLoad)
|
|
101
|
+
existingScript.removeEventListener('error', onScriptError)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const onScriptLoad = (): void => {
|
|
105
|
+
// Wait a bit for turnstile to initialize
|
|
106
|
+
loadCheckIntervalId = setInterval(() => {
|
|
107
|
+
if (window.turnstile) {
|
|
108
|
+
cleanup()
|
|
109
|
+
resolve(undefined)
|
|
110
|
+
}
|
|
111
|
+
}, 50)
|
|
112
|
+
|
|
113
|
+
// Timeout if turnstile doesn't initialize
|
|
114
|
+
setTimeout(() => {
|
|
115
|
+
cleanup()
|
|
116
|
+
if (!window.turnstile) {
|
|
117
|
+
reject(new TurnstileScriptLoadError('Turnstile did not initialize after script load'))
|
|
118
|
+
} else {
|
|
119
|
+
resolve(undefined)
|
|
120
|
+
}
|
|
121
|
+
}, timeoutMs)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const onScriptError = (): void => {
|
|
125
|
+
cleanup()
|
|
126
|
+
reject(new TurnstileScriptLoadError('Failed to load Turnstile script'))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Script exists but not loaded yet, listen for load event
|
|
130
|
+
// For scripts with defer/async, the load event will fire when ready
|
|
131
|
+
existingScript.addEventListener('load', onScriptLoad)
|
|
132
|
+
existingScript.addEventListener('error', onScriptError)
|
|
133
|
+
|
|
134
|
+
// Also check periodically in case the script loaded before we attached listeners
|
|
135
|
+
// (race condition with defer/async scripts)
|
|
136
|
+
raceCheckIntervalId = setInterval(() => {
|
|
137
|
+
if (window.turnstile) {
|
|
138
|
+
cleanup()
|
|
139
|
+
resolve(undefined)
|
|
140
|
+
}
|
|
141
|
+
}, 50)
|
|
142
|
+
|
|
143
|
+
// Timeout fallback
|
|
144
|
+
setTimeout(() => {
|
|
145
|
+
cleanup()
|
|
146
|
+
if (!window.turnstile) {
|
|
147
|
+
reject(new TurnstileScriptLoadError('Turnstile script loaded but API not available'))
|
|
148
|
+
} else {
|
|
149
|
+
resolve(undefined)
|
|
150
|
+
}
|
|
151
|
+
}, timeoutMs)
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
let resolved = false
|
|
156
|
+
const timeoutId = setTimeout(() => {
|
|
157
|
+
if (!resolved) {
|
|
158
|
+
resolved = true
|
|
159
|
+
reject(new TurnstileScriptLoadError(`Script observation timeout after ${timeoutMs}ms`))
|
|
160
|
+
}
|
|
161
|
+
}, timeoutMs)
|
|
162
|
+
|
|
163
|
+
const observer = new MutationObserver(() => {
|
|
164
|
+
const script = document.getElementById(scriptId) as HTMLScriptElement | null
|
|
165
|
+
if (script && window.turnstile) {
|
|
166
|
+
if (!resolved) {
|
|
167
|
+
resolved = true
|
|
168
|
+
clearTimeout(timeoutId)
|
|
169
|
+
observer.disconnect()
|
|
170
|
+
resolve(undefined)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
// Start observing
|
|
176
|
+
observer.observe(document.head, {
|
|
177
|
+
childList: true,
|
|
178
|
+
subtree: true,
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
// Also check immediately in case script was added synchronously
|
|
182
|
+
const scriptCheck = document.getElementById(scriptId) as HTMLScriptElement | null
|
|
183
|
+
if (scriptCheck && window.turnstile) {
|
|
184
|
+
resolved = true
|
|
185
|
+
clearTimeout(timeoutId)
|
|
186
|
+
observer.disconnect()
|
|
187
|
+
resolve(undefined)
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Injects Turnstile script with CSP-compliant options
|
|
195
|
+
*/
|
|
196
|
+
function injectTurnstileScript(options: TurnstileScriptOptions = {}): void {
|
|
197
|
+
const scriptId = options.id || DEFAULT_SCRIPT_ID
|
|
198
|
+
const onLoadCallbackName = options.onLoadCallbackName || DEFAULT_ONLOAD_NAME
|
|
199
|
+
|
|
200
|
+
// Check if script already exists
|
|
201
|
+
if (document.getElementById(scriptId)) {
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Set up global onload callback
|
|
206
|
+
// Use unknown first to avoid type errors
|
|
207
|
+
const windowWithCallback = window as unknown as Record<string, unknown>
|
|
208
|
+
windowWithCallback[onLoadCallbackName] = (): void => {
|
|
209
|
+
turnstileState.setState('ready')
|
|
210
|
+
turnstileState.resolveLoad()
|
|
211
|
+
// Clean up callback
|
|
212
|
+
delete windowWithCallback[onLoadCallbackName]
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Create script element
|
|
216
|
+
const script = document.createElement('script')
|
|
217
|
+
script.id = scriptId
|
|
218
|
+
script.src = TURNSTILE_SCRIPT_URL
|
|
219
|
+
|
|
220
|
+
// Apply CSP-compliant options
|
|
221
|
+
if (options.nonce) {
|
|
222
|
+
script.nonce = options.nonce
|
|
223
|
+
}
|
|
224
|
+
if (options.defer !== undefined) {
|
|
225
|
+
script.defer = options.defer
|
|
226
|
+
}
|
|
227
|
+
if (options.async !== undefined) {
|
|
228
|
+
script.async = options.async
|
|
229
|
+
}
|
|
230
|
+
if (options.crossOrigin) {
|
|
231
|
+
script.crossOrigin = options.crossOrigin
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Track interval so it can be cleaned up from timeout
|
|
235
|
+
let checkIntervalId: ReturnType<typeof setInterval> | null = null
|
|
236
|
+
|
|
237
|
+
// Set onload callback via script attribute for CSP compliance
|
|
238
|
+
script.onload = (): void => {
|
|
239
|
+
// Wait for turnstile to actually be available
|
|
240
|
+
// The global callback will handle state transition
|
|
241
|
+
checkIntervalId = setInterval(() => {
|
|
242
|
+
if (window.turnstile) {
|
|
243
|
+
if (checkIntervalId) {
|
|
244
|
+
clearInterval(checkIntervalId)
|
|
245
|
+
}
|
|
246
|
+
// If global callback hasn't fired, resolve manually
|
|
247
|
+
if (turnstileState.getState() !== 'ready') {
|
|
248
|
+
turnstileState.setState('ready')
|
|
249
|
+
turnstileState.resolveLoad()
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}, 50)
|
|
253
|
+
|
|
254
|
+
// Fallback timeout
|
|
255
|
+
setTimeout(() => {
|
|
256
|
+
if (checkIntervalId) {
|
|
257
|
+
clearInterval(checkIntervalId)
|
|
258
|
+
}
|
|
259
|
+
// Only reject if BOTH conditions are true: state isn't ready AND turnstile doesn't exist.
|
|
260
|
+
// If turnstile exists but state isn't ready, the interval will handle it.
|
|
261
|
+
// If state is ready, the global callback already resolved.
|
|
262
|
+
if (turnstileState.getState() !== 'ready' && !window.turnstile) {
|
|
263
|
+
turnstileState.rejectLoad(new TurnstileScriptLoadError('Turnstile did not initialize after script load'))
|
|
264
|
+
}
|
|
265
|
+
}, 5000)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
script.onerror = (): void => {
|
|
269
|
+
turnstileState.setState('unloaded')
|
|
270
|
+
turnstileState.rejectLoad(new TurnstileScriptLoadError('Failed to load Turnstile script'))
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Inject script
|
|
274
|
+
document.head.appendChild(script)
|
|
275
|
+
turnstileState.setState('loading')
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Ensures Turnstile script is loaded, using state machine for coordination
|
|
280
|
+
*/
|
|
281
|
+
async function ensureTurnstileScript(scriptOptions: TurnstileScriptOptions = {}): Promise<void> {
|
|
282
|
+
// If already ready, return immediately
|
|
283
|
+
if (turnstileState.getState() === 'ready' && window.turnstile) {
|
|
284
|
+
return Promise.resolve()
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Check if script exists in DOM (including preloaded scripts)
|
|
288
|
+
const scriptId = scriptOptions.id || DEFAULT_SCRIPT_ID
|
|
289
|
+
const existingScript = document.getElementById(scriptId) as HTMLScriptElement | null
|
|
290
|
+
|
|
291
|
+
if (existingScript) {
|
|
292
|
+
// Script exists in DOM - check if it's already loaded
|
|
293
|
+
if (window.turnstile) {
|
|
294
|
+
turnstileState.setState('ready')
|
|
295
|
+
turnstileState.resolveLoad()
|
|
296
|
+
return Promise.resolve()
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Script exists but not loaded yet - observe for loading
|
|
300
|
+
// This handles preloaded scripts with defer/async
|
|
301
|
+
turnstileState.setState('loading')
|
|
302
|
+
return observeScriptLoad(scriptId).then(() => {
|
|
303
|
+
turnstileState.setState('ready')
|
|
304
|
+
turnstileState.resolveLoad()
|
|
305
|
+
})
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// If already loading (from a previous call), wait for existing promise
|
|
309
|
+
if (turnstileState.getState() === 'loading') {
|
|
310
|
+
return turnstileState.getLoadPromise()
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Script doesn't exist - inject it
|
|
314
|
+
injectTurnstileScript(scriptOptions)
|
|
315
|
+
return turnstileState.getLoadPromise()
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Reset turnstile state for testing purposes
|
|
320
|
+
*/
|
|
321
|
+
function resetTurnstileState(): void {
|
|
322
|
+
turnstileState.reset()
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export { ensureTurnstileScript, observeScriptLoad, turnstileState, resetTurnstileState }
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/// @vitest-environment happy-dom
|
|
2
|
+
import { ChallengeType } from '@luxamm/client-platform-service/dist/lx/platformservice/v1/sessionService_pb'
|
|
3
|
+
import { createTurnstileSolver } from '@l.x/sessions/src/challenge-solvers/createTurnstileSolver'
|
|
4
|
+
import { resetTurnstileState } from '@l.x/sessions/src/challenge-solvers/turnstileScriptLoader'
|
|
5
|
+
import type { PerformanceTracker } from '@l.x/sessions/src/performance/types'
|
|
6
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
7
|
+
|
|
8
|
+
// Mock performance tracker for testing
|
|
9
|
+
function createMockPerformanceTracker(): PerformanceTracker {
|
|
10
|
+
let time = 0
|
|
11
|
+
return {
|
|
12
|
+
now: (): number => {
|
|
13
|
+
time += 100
|
|
14
|
+
return time
|
|
15
|
+
},
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Mock window.turnstile API
|
|
20
|
+
const mockTurnstileAPI = {
|
|
21
|
+
render: vi.fn(),
|
|
22
|
+
remove: vi.fn(),
|
|
23
|
+
reset: vi.fn(),
|
|
24
|
+
getResponse: vi.fn(),
|
|
25
|
+
ready: vi.fn(),
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Setup DOM mocks
|
|
29
|
+
beforeAll(() => {
|
|
30
|
+
const originalCreateElement = document.createElement.bind(document)
|
|
31
|
+
vi.spyOn(document, 'createElement').mockImplementation((tagName: string) => {
|
|
32
|
+
const element = originalCreateElement(tagName)
|
|
33
|
+
if (tagName === 'div') {
|
|
34
|
+
// Track created divs for assertions
|
|
35
|
+
// eslint-disable-next-line no-extra-semi
|
|
36
|
+
;(element as any)._testCreated = true
|
|
37
|
+
}
|
|
38
|
+
return element
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
vi.spyOn(document.head, 'appendChild').mockImplementation((node) => {
|
|
42
|
+
if (node instanceof HTMLScriptElement && node.src.includes('challenges.cloudflare.com')) {
|
|
43
|
+
// Simulate script load immediately
|
|
44
|
+
setTimeout(() => {
|
|
45
|
+
// Set up the mock turnstile API
|
|
46
|
+
// eslint-disable-next-line no-extra-semi
|
|
47
|
+
;(window as any).turnstile = mockTurnstileAPI
|
|
48
|
+
if (node.onload) {
|
|
49
|
+
node.onload({} as Event)
|
|
50
|
+
}
|
|
51
|
+
}, 0)
|
|
52
|
+
}
|
|
53
|
+
return node
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const originalBodyAppendChild = document.body.appendChild.bind(document.body)
|
|
57
|
+
vi.spyOn(document.body, 'appendChild').mockImplementation((node) => {
|
|
58
|
+
// eslint-disable-next-line no-extra-semi
|
|
59
|
+
;(node as any)._testAppended = true
|
|
60
|
+
// Actually append to the DOM so we can query it later
|
|
61
|
+
return originalBodyAppendChild(node)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
vi.spyOn(Element.prototype, 'removeChild').mockImplementation(function (this: Element, child: Node) {
|
|
65
|
+
// eslint-disable-next-line no-extra-semi
|
|
66
|
+
;(child as any)._testRemoved = true
|
|
67
|
+
return child
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
afterAll(() => {
|
|
72
|
+
vi.restoreAllMocks()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
describe('Turnstile Solver Integration Tests', () => {
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
// Configure mock Turnstile API behavior
|
|
78
|
+
mockTurnstileAPI.ready.mockImplementation((callback: () => void) => {
|
|
79
|
+
// Call the callback immediately
|
|
80
|
+
callback()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
mockTurnstileAPI.render.mockImplementation((container: string | HTMLElement, options: any) => {
|
|
84
|
+
// Simulate successful render and call the callback with a test token
|
|
85
|
+
if (options.callback) {
|
|
86
|
+
setTimeout(() => {
|
|
87
|
+
options.callback('test-turnstile-solution-token')
|
|
88
|
+
}, 10) // Small delay to simulate async behavior
|
|
89
|
+
}
|
|
90
|
+
return 'widget-123' // Return a mock widget ID
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
afterEach(() => {
|
|
95
|
+
// Clean up DOM
|
|
96
|
+
document.querySelectorAll('div[id^="turnstile-"]').forEach((el) => el.remove())
|
|
97
|
+
|
|
98
|
+
// Reset mocks to default successful behavior
|
|
99
|
+
vi.clearAllMocks()
|
|
100
|
+
;(window as any).turnstile = undefined
|
|
101
|
+
|
|
102
|
+
// Reset turnstile script loader state for next test
|
|
103
|
+
resetTurnstileState()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('verifies Turnstile solver basic functionality', async () => {
|
|
107
|
+
// Create a challenge solver directly to test
|
|
108
|
+
const turnstileSolver = createTurnstileSolver({ performanceTracker: createMockPerformanceTracker() })
|
|
109
|
+
|
|
110
|
+
// Create challenge data with proper structure
|
|
111
|
+
const challengeData = {
|
|
112
|
+
challengeId: 'dom-test-challenge-123',
|
|
113
|
+
challengeType: ChallengeType.TURNSTILE,
|
|
114
|
+
extra: {
|
|
115
|
+
challengeData: JSON.stringify({
|
|
116
|
+
siteKey: '0x4AAAAAABiAHneWOWZHzZtO',
|
|
117
|
+
action: 'session_verification',
|
|
118
|
+
}),
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Execute the solver and wait for solution
|
|
123
|
+
const solution = await turnstileSolver.solve(challengeData)
|
|
124
|
+
|
|
125
|
+
// Verify solution was returned
|
|
126
|
+
expect(solution).toBe('test-turnstile-solution-token')
|
|
127
|
+
|
|
128
|
+
// Verify script injection was attempted
|
|
129
|
+
expect(document.head.appendChild).toHaveBeenCalledWith(
|
|
130
|
+
expect.objectContaining({
|
|
131
|
+
src: 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit',
|
|
132
|
+
}),
|
|
133
|
+
)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('handles Turnstile solver errors properly', async () => {
|
|
137
|
+
// Configure mock to simulate an error with proper timing
|
|
138
|
+
mockTurnstileAPI.render.mockImplementation(async (container: string | HTMLElement, options: any) => {
|
|
139
|
+
if (options['error-callback']) {
|
|
140
|
+
// Use microtask to ensure promise handlers are set up
|
|
141
|
+
await Promise.resolve().then(() => {
|
|
142
|
+
options['error-callback']('NETWORK_ERROR')
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
return 'widget-error-123'
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
const turnstileSolver = createTurnstileSolver({ performanceTracker: createMockPerformanceTracker() })
|
|
149
|
+
const challengeData = {
|
|
150
|
+
challengeId: 'error-test-123',
|
|
151
|
+
challengeType: ChallengeType.TURNSTILE,
|
|
152
|
+
extra: {
|
|
153
|
+
challengeData: JSON.stringify({
|
|
154
|
+
siteKey: 'test-site-key',
|
|
155
|
+
action: 'test-action',
|
|
156
|
+
}),
|
|
157
|
+
},
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Should reject with Turnstile error
|
|
161
|
+
await expect(turnstileSolver.solve(challengeData)).rejects.toThrow('Turnstile error: NETWORK_ERROR')
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('handles expired tokens', async () => {
|
|
165
|
+
// Configure mock to simulate token expiration
|
|
166
|
+
mockTurnstileAPI.render.mockImplementation(async (container: string | HTMLElement, options: any) => {
|
|
167
|
+
if (options['expired-callback']) {
|
|
168
|
+
// Use microtask to ensure promise handlers are set up
|
|
169
|
+
await Promise.resolve().then(() => {
|
|
170
|
+
options['expired-callback']()
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
return 'widget-expired-123'
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
const turnstileSolver = createTurnstileSolver({ performanceTracker: createMockPerformanceTracker() })
|
|
177
|
+
const challengeData = {
|
|
178
|
+
challengeId: 'expired-test-123',
|
|
179
|
+
challengeType: ChallengeType.TURNSTILE,
|
|
180
|
+
extra: {
|
|
181
|
+
challengeData: JSON.stringify({
|
|
182
|
+
siteKey: 'test-site-key',
|
|
183
|
+
action: 'test-action',
|
|
184
|
+
}),
|
|
185
|
+
},
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Should reject with expiration error
|
|
189
|
+
await expect(turnstileSolver.solve(challengeData)).rejects.toThrow('Turnstile token expired')
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('handles timeout scenarios', async () => {
|
|
193
|
+
// Configure mock to never call any callbacks
|
|
194
|
+
mockTurnstileAPI.render.mockImplementation(() => {
|
|
195
|
+
// Don't call any callbacks - simulate timeout
|
|
196
|
+
return 'widget-timeout-123'
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
// Use a short timeout for testing (100ms instead of 30s)
|
|
200
|
+
const turnstileSolver = createTurnstileSolver({
|
|
201
|
+
performanceTracker: createMockPerformanceTracker(),
|
|
202
|
+
timeoutMs: 100,
|
|
203
|
+
})
|
|
204
|
+
const challengeData = {
|
|
205
|
+
challengeId: 'timeout-test-123',
|
|
206
|
+
challengeType: ChallengeType.TURNSTILE,
|
|
207
|
+
extra: {
|
|
208
|
+
challengeData: JSON.stringify({
|
|
209
|
+
siteKey: 'test-site-key',
|
|
210
|
+
action: 'test-action',
|
|
211
|
+
}),
|
|
212
|
+
},
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Should reject with timeout error after 100ms
|
|
216
|
+
await expect(turnstileSolver.solve(challengeData)).rejects.toThrow('Turnstile challenge timed out after')
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('handles missing challenge data', async () => {
|
|
220
|
+
const turnstileSolver = createTurnstileSolver({ performanceTracker: createMockPerformanceTracker() })
|
|
221
|
+
const challengeData = {
|
|
222
|
+
challengeId: 'missing-data-123',
|
|
223
|
+
challengeType: ChallengeType.TURNSTILE,
|
|
224
|
+
extra: {}, // Missing challengeData
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Should reject with missing data error
|
|
228
|
+
await expect(turnstileSolver.solve(challengeData)).rejects.toThrow('Missing challengeData in challenge extra')
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('handles invalid challenge data JSON', async () => {
|
|
232
|
+
const turnstileSolver = createTurnstileSolver({ performanceTracker: createMockPerformanceTracker() })
|
|
233
|
+
const challengeData = {
|
|
234
|
+
challengeId: 'invalid-json-123',
|
|
235
|
+
challengeType: ChallengeType.TURNSTILE,
|
|
236
|
+
extra: {
|
|
237
|
+
challengeData: 'invalid-json-{',
|
|
238
|
+
},
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Should reject with JSON parse error
|
|
242
|
+
await expect(turnstileSolver.solve(challengeData)).rejects.toThrow('Failed to parse challengeData')
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('handles missing siteKey in challenge data', async () => {
|
|
246
|
+
const turnstileSolver = createTurnstileSolver({ performanceTracker: createMockPerformanceTracker() })
|
|
247
|
+
const challengeData = {
|
|
248
|
+
challengeId: 'missing-sitekey-123',
|
|
249
|
+
challengeType: ChallengeType.TURNSTILE,
|
|
250
|
+
extra: {
|
|
251
|
+
challengeData: JSON.stringify({
|
|
252
|
+
action: 'test-action',
|
|
253
|
+
// Missing siteKey
|
|
254
|
+
}),
|
|
255
|
+
},
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Should reject with missing siteKey error
|
|
259
|
+
await expect(turnstileSolver.solve(challengeData)).rejects.toThrow('Missing siteKey in challengeData')
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it('handles script loading failures', async () => {
|
|
263
|
+
// Mock script loading failure
|
|
264
|
+
vi.spyOn(document.head, 'appendChild').mockImplementationOnce((node) => {
|
|
265
|
+
if (node instanceof HTMLScriptElement && node.src.includes('challenges.cloudflare.com')) {
|
|
266
|
+
setTimeout(() => {
|
|
267
|
+
if (node.onerror) {
|
|
268
|
+
node.onerror({} as Event)
|
|
269
|
+
}
|
|
270
|
+
}, 0)
|
|
271
|
+
}
|
|
272
|
+
return node
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
const turnstileSolver = createTurnstileSolver({ performanceTracker: createMockPerformanceTracker() })
|
|
276
|
+
const challengeData = {
|
|
277
|
+
challengeId: 'script-fail-123',
|
|
278
|
+
challengeType: ChallengeType.TURNSTILE,
|
|
279
|
+
extra: {
|
|
280
|
+
challengeData: JSON.stringify({
|
|
281
|
+
siteKey: 'test-site-key',
|
|
282
|
+
action: 'test-action',
|
|
283
|
+
}),
|
|
284
|
+
},
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Should reject with script loading error
|
|
288
|
+
await expect(turnstileSolver.solve(challengeData)).rejects.toThrow('Failed to load Turnstile script')
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
it('handles multiple concurrent solve requests', async () => {
|
|
292
|
+
const turnstileSolver = createTurnstileSolver({ performanceTracker: createMockPerformanceTracker() })
|
|
293
|
+
|
|
294
|
+
// Create multiple challenge data objects
|
|
295
|
+
const challenges = Array.from({ length: 3 }, (_, i) => ({
|
|
296
|
+
challengeId: `concurrent-test-${i}`,
|
|
297
|
+
challengeType: ChallengeType.TURNSTILE,
|
|
298
|
+
extra: {
|
|
299
|
+
challengeData: JSON.stringify({
|
|
300
|
+
siteKey: `test-site-key-${i}`,
|
|
301
|
+
action: 'test-action',
|
|
302
|
+
}),
|
|
303
|
+
},
|
|
304
|
+
}))
|
|
305
|
+
|
|
306
|
+
// Configure mock to return different tokens for each widget
|
|
307
|
+
let widgetCounter = 0
|
|
308
|
+
mockTurnstileAPI.render.mockImplementation((container: string | HTMLElement, options: any) => {
|
|
309
|
+
const widgetId = `widget-${widgetCounter++}`
|
|
310
|
+
if (options.callback) {
|
|
311
|
+
setTimeout(() => {
|
|
312
|
+
options.callback(`solution-for-${widgetId}`)
|
|
313
|
+
}, 10)
|
|
314
|
+
}
|
|
315
|
+
return widgetId
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
// Execute all solvers concurrently
|
|
319
|
+
const solutionPromises = challenges.map((challenge) => turnstileSolver.solve(challenge))
|
|
320
|
+
|
|
321
|
+
// Wait for all solutions
|
|
322
|
+
const solutions = await Promise.all(solutionPromises)
|
|
323
|
+
|
|
324
|
+
// Verify all solutions are unique
|
|
325
|
+
expect(solutions).toHaveLength(3)
|
|
326
|
+
expect(new Set(solutions).size).toBe(3)
|
|
327
|
+
solutions.forEach((solution) => {
|
|
328
|
+
expect(solution).toMatch(/^solution-for-widget-\d+$/)
|
|
329
|
+
})
|
|
330
|
+
})
|
|
331
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { TypedChallengeData } from '@l.x/sessions/src/session-repository/types'
|
|
2
|
+
import type { ChallengeType } from '@l.x/sessions/src/session-service/types'
|
|
3
|
+
|
|
4
|
+
interface ChallengeData {
|
|
5
|
+
challengeId: string
|
|
6
|
+
challengeType: ChallengeType
|
|
7
|
+
/** @deprecated Use challengeData instead */
|
|
8
|
+
extra?: Record<string, string>
|
|
9
|
+
/** Type-safe challenge-specific data (replaces extra) */
|
|
10
|
+
challengeData?: TypedChallengeData
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface ChallengeSolver {
|
|
14
|
+
solve(challengeData: ChallengeData): Promise<string>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ChallengeSolverService {
|
|
18
|
+
getSolver(type: ChallengeType): ChallengeSolver | null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Script injection options for CSP compliance and customization
|
|
23
|
+
*/
|
|
24
|
+
interface TurnstileScriptOptions {
|
|
25
|
+
/**
|
|
26
|
+
* Custom nonce for the injected script (for CSP compliance)
|
|
27
|
+
*/
|
|
28
|
+
nonce?: string
|
|
29
|
+
/**
|
|
30
|
+
* Whether to set the script as defer
|
|
31
|
+
* @default false (Turnstile requires synchronous loading for ready())
|
|
32
|
+
*/
|
|
33
|
+
defer?: boolean
|
|
34
|
+
/**
|
|
35
|
+
* Whether to set the script as async
|
|
36
|
+
* @default false (Turnstile requires synchronous loading for ready())
|
|
37
|
+
*/
|
|
38
|
+
async?: boolean
|
|
39
|
+
/**
|
|
40
|
+
* Custom crossOrigin for the injected script
|
|
41
|
+
*/
|
|
42
|
+
crossOrigin?: string
|
|
43
|
+
/**
|
|
44
|
+
* Custom ID for the injected script
|
|
45
|
+
* @default "cf-turnstile-script"
|
|
46
|
+
*/
|
|
47
|
+
id?: string
|
|
48
|
+
/**
|
|
49
|
+
* Custom name for the onload callback
|
|
50
|
+
* @default "onloadTurnstileCallback"
|
|
51
|
+
*/
|
|
52
|
+
onLoadCallbackName?: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type { ChallengeData, ChallengeSolver, ChallengeSolverService, TurnstileScriptOptions }
|