@luxexchange/sessions 1.0.0
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/README.md +1 -0
- package/env.d.ts +12 -0
- package/package.json +50 -0
- package/project.json +42 -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 +255 -0
- package/src/challenge-solvers/createNoneMockSolver.ts +11 -0
- package/src/challenge-solvers/createTurnstileMockSolver.ts +30 -0
- package/src/challenge-solvers/createTurnstileSolver.ts +353 -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 +137 -0
- package/src/lux-identifier/createLuxIdentifierService.ts +19 -0
- package/src/lux-identifier/luxIdentifierQuery.ts +20 -0
- package/src/lux-identifier/types.ts +11 -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 +193 -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 +480 -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/tsconfig.json +19 -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
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import type { ChallengeSolverService } from '@luxexchange/sessions/src/challenge-solvers/types'
|
|
2
|
+
import type { PerformanceTracker } from '@luxexchange/sessions/src/performance/types'
|
|
3
|
+
import {
|
|
4
|
+
MaxChallengeRetriesError,
|
|
5
|
+
NoSolverAvailableError,
|
|
6
|
+
} from '@luxexchange/sessions/src/session-initialization/sessionErrors'
|
|
7
|
+
import type { SessionService } from '@luxexchange/sessions/src/session-service/types'
|
|
8
|
+
import type { Logger } from '@luxfi/utilities/src/logger/logger'
|
|
9
|
+
|
|
10
|
+
interface SessionInitResult {
|
|
11
|
+
sessionId: string | null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Callbacks for session initialization lifecycle events.
|
|
16
|
+
* Each callback is optional and focused on one event.
|
|
17
|
+
*/
|
|
18
|
+
export interface SessionInitAnalytics {
|
|
19
|
+
/** Called when session initialization starts */
|
|
20
|
+
onInitStarted?: () => void
|
|
21
|
+
/** Called when session initialization completes (before challenge flow) */
|
|
22
|
+
onInitCompleted?: (data: { needChallenge: boolean; durationMs: number }) => void
|
|
23
|
+
/** Called when a challenge is received from the backend */
|
|
24
|
+
onChallengeReceived?: (data: { challengeType: string; challengeId: string }) => void
|
|
25
|
+
/** Called when session verification completes (success or retry) */
|
|
26
|
+
onVerifyCompleted?: (data: { success: boolean; attemptNumber: number; totalDurationMs: number }) => void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface SessionInitOptions {
|
|
30
|
+
/**
|
|
31
|
+
* If provided, skips the initSession() RPC call and uses this value directly.
|
|
32
|
+
* Useful when the caller already knows the answer (e.g. from a prior init call).
|
|
33
|
+
*/
|
|
34
|
+
needChallenge?: boolean
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface SessionInitializationService {
|
|
38
|
+
/**
|
|
39
|
+
* Orchestrates the complete session initialization flow:
|
|
40
|
+
* 1. Calls initSession (backend decides whether to create new or reuse existing)
|
|
41
|
+
* 2. Handles challenge solving if required
|
|
42
|
+
*
|
|
43
|
+
* Pass `options.needChallenge` to skip step 1 when the answer is already known.
|
|
44
|
+
*
|
|
45
|
+
* @throws Error if initialization fails
|
|
46
|
+
*/
|
|
47
|
+
initialize(options?: SessionInitOptions): Promise<SessionInitResult>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function createSessionInitializationService(ctx: {
|
|
51
|
+
getSessionService: () => SessionService
|
|
52
|
+
challengeSolverService: ChallengeSolverService
|
|
53
|
+
/**
|
|
54
|
+
* Required: Performance tracker for timing measurements.
|
|
55
|
+
* Must be injected - no implicit dependency on globalThis.performance.
|
|
56
|
+
*/
|
|
57
|
+
performanceTracker: PerformanceTracker
|
|
58
|
+
getIsSessionUpgradeAutoEnabled?: () => boolean
|
|
59
|
+
maxChallengeRetries?: number
|
|
60
|
+
getLogger?: () => Logger
|
|
61
|
+
/** Analytics callbacks for tracking session initialization lifecycle */
|
|
62
|
+
analytics?: SessionInitAnalytics
|
|
63
|
+
}): SessionInitializationService {
|
|
64
|
+
async function handleChallengeFlow(attemptCount = 0, flowStartTime?: number): Promise<void> {
|
|
65
|
+
const startTime = flowStartTime ?? ctx.performanceTracker.now()
|
|
66
|
+
const maxRetries = ctx.maxChallengeRetries ?? 3
|
|
67
|
+
|
|
68
|
+
const challenge = await ctx.getSessionService().requestChallenge()
|
|
69
|
+
|
|
70
|
+
ctx.getLogger?.().debug('createSessionInitializationService', 'handleChallengeFlow', 'Requesting challenge', {
|
|
71
|
+
challenge,
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
// Report challenge received (only on first attempt)
|
|
75
|
+
if (attemptCount === 0) {
|
|
76
|
+
ctx.analytics?.onChallengeReceived?.({
|
|
77
|
+
challengeType: String(challenge.challengeType),
|
|
78
|
+
challengeId: challenge.challengeId,
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// get our solver for the challenge type
|
|
83
|
+
const solver = ctx.challengeSolverService.getSolver(challenge.challengeType)
|
|
84
|
+
if (!solver) {
|
|
85
|
+
throw new NoSolverAvailableError(challenge.challengeType)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Solve the challenge — if the solver throws (e.g. Turnstile domain mismatch on
|
|
89
|
+
// Vercel previews), submit a placeholder solution so verifySession can reject it
|
|
90
|
+
// and the retry loop can request a different challenge type (typically Hashcash).
|
|
91
|
+
// Note: we use a non-empty placeholder because proto3 omits empty strings from the
|
|
92
|
+
// wire, which means the backend wouldn't see the solution field at all.
|
|
93
|
+
let solution: string
|
|
94
|
+
try {
|
|
95
|
+
solution = await solver.solve({
|
|
96
|
+
challengeId: challenge.challengeId,
|
|
97
|
+
challengeType: challenge.challengeType,
|
|
98
|
+
extra: challenge.extra,
|
|
99
|
+
challengeData: challenge.challengeData,
|
|
100
|
+
})
|
|
101
|
+
} catch (solverError) {
|
|
102
|
+
ctx
|
|
103
|
+
.getLogger?.()
|
|
104
|
+
.warn(
|
|
105
|
+
'createSessionInitializationService',
|
|
106
|
+
'handleChallengeFlow',
|
|
107
|
+
'Solver failed, submitting placeholder solution to trigger fallback',
|
|
108
|
+
{ error: solverError, challengeType: challenge.challengeType },
|
|
109
|
+
)
|
|
110
|
+
solution = 'solver-failed'
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
ctx
|
|
114
|
+
.getLogger?.()
|
|
115
|
+
.debug('createSessionInitializationService', 'handleChallengeFlow', 'Solved challenge', { solution })
|
|
116
|
+
|
|
117
|
+
// Verify session with the solution
|
|
118
|
+
const result = await ctx.getSessionService().verifySession({
|
|
119
|
+
solution,
|
|
120
|
+
challengeId: challenge.challengeId,
|
|
121
|
+
challengeType: challenge.challengeType,
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
if (!result.retry) {
|
|
125
|
+
// Verification was successful
|
|
126
|
+
ctx.analytics?.onVerifyCompleted?.({
|
|
127
|
+
success: true,
|
|
128
|
+
attemptNumber: attemptCount + 1,
|
|
129
|
+
totalDurationMs: ctx.performanceTracker.now() - startTime,
|
|
130
|
+
})
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Report retry (verification failed but will retry)
|
|
135
|
+
ctx.analytics?.onVerifyCompleted?.({
|
|
136
|
+
success: false,
|
|
137
|
+
attemptNumber: attemptCount + 1,
|
|
138
|
+
totalDurationMs: ctx.performanceTracker.now() - startTime,
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
// Handle server retry request
|
|
142
|
+
if (attemptCount >= maxRetries) {
|
|
143
|
+
throw new MaxChallengeRetriesError(maxRetries, attemptCount + 1)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
await handleChallengeFlow(attemptCount + 1, startTime) // Recursive call with incremented count
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function initialize(options?: SessionInitOptions): Promise<SessionInitResult> {
|
|
150
|
+
const initStartTime = ctx.performanceTracker.now()
|
|
151
|
+
|
|
152
|
+
let needChallenge: boolean
|
|
153
|
+
let sessionId: string | undefined
|
|
154
|
+
|
|
155
|
+
if (options?.needChallenge !== undefined) {
|
|
156
|
+
// Caller already knows — skip the initSession() RPC
|
|
157
|
+
needChallenge = options.needChallenge
|
|
158
|
+
sessionId = undefined
|
|
159
|
+
|
|
160
|
+
ctx.analytics?.onInitCompleted?.({
|
|
161
|
+
needChallenge,
|
|
162
|
+
durationMs: 0,
|
|
163
|
+
})
|
|
164
|
+
} else {
|
|
165
|
+
// Discover from backend
|
|
166
|
+
ctx.analytics?.onInitStarted?.()
|
|
167
|
+
|
|
168
|
+
const initResponse = await ctx.getSessionService().initSession()
|
|
169
|
+
needChallenge = initResponse.needChallenge
|
|
170
|
+
sessionId = initResponse.sessionId
|
|
171
|
+
|
|
172
|
+
ctx.analytics?.onInitCompleted?.({
|
|
173
|
+
needChallenge,
|
|
174
|
+
durationMs: ctx.performanceTracker.now() - initStartTime,
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Handle challenge if required and enabled
|
|
179
|
+
if (needChallenge && ctx.getIsSessionUpgradeAutoEnabled?.()) {
|
|
180
|
+
await handleChallengeFlow()
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// sessionId is null for web (stored in cookie), real ID for non-web platforms
|
|
184
|
+
return {
|
|
185
|
+
sessionId: sessionId ?? null,
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return { initialize }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export { createSessionInitializationService }
|
|
193
|
+
export type { SessionInitializationService, SessionInitOptions, SessionInitResult }
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base class for session-related errors that should not trigger retries at higher levels.
|
|
3
|
+
*/
|
|
4
|
+
export abstract class SessionError extends Error {
|
|
5
|
+
constructor(message: string, name: string) {
|
|
6
|
+
super(message)
|
|
7
|
+
this.name = name
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Error thrown when maximum challenge retry attempts are exceeded.
|
|
13
|
+
* This error should not trigger additional retries at higher levels.
|
|
14
|
+
*/
|
|
15
|
+
export class MaxChallengeRetriesError extends SessionError {
|
|
16
|
+
constructor(maxRetries: number, actualAttempts: number) {
|
|
17
|
+
super(
|
|
18
|
+
`Maximum challenge retry attempts (${maxRetries}) exceeded after ${actualAttempts} attempts`,
|
|
19
|
+
'MaxChallengeRetriesError',
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Error thrown when no solver is available for a challenge type.
|
|
26
|
+
* This error should not trigger additional retries at higher levels.
|
|
27
|
+
*/
|
|
28
|
+
export class NoSolverAvailableError extends SessionError {
|
|
29
|
+
constructor(challengeType: number) {
|
|
30
|
+
super(`No solver available for challenge type: ${challengeType}`, 'NoSolverAvailableError')
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createPromiseClient, type PromiseClient, type Transport } from '@connectrpc/connect'
|
|
2
|
+
import { SessionService } from '@luxdex/client-platform-service/dist/uniswap/platformservice/v1/sessionService_connect'
|
|
3
|
+
|
|
4
|
+
type SessionServiceClient = PromiseClient<typeof SessionService>
|
|
5
|
+
|
|
6
|
+
function createSessionClient(ctx: { transport: Transport }): PromiseClient<typeof SessionService> {
|
|
7
|
+
return createPromiseClient(SessionService, ctx.transport)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export { type SessionServiceClient, createSessionClient }
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import type { PromiseClient } from '@connectrpc/connect'
|
|
2
|
+
import type { SessionService } from '@uniswap/client-platform-service/dist/uniswap/platformservice/v1/sessionService_connect'
|
|
3
|
+
import {
|
|
4
|
+
ChallengeFailure,
|
|
5
|
+
ChallengeFailure_Reason,
|
|
6
|
+
ChallengeResponse,
|
|
7
|
+
GetChallengeTypesResponse,
|
|
8
|
+
InitSessionResponse,
|
|
9
|
+
SignoutResponse,
|
|
10
|
+
} from '@uniswap/client-platform-service/dist/uniswap/platformservice/v1/sessionService_pb'
|
|
11
|
+
import { createSessionRepository } from '@luxexchange/sessions/src/session-repository/createSessionRepository'
|
|
12
|
+
import { ChallengeRejectedError } from '@luxexchange/sessions/src/session-repository/errors'
|
|
13
|
+
import { ChallengeType } from '@luxexchange/sessions/src/session-service/types'
|
|
14
|
+
import { describe, expect, it, type MockedFunction, vi } from 'vitest'
|
|
15
|
+
|
|
16
|
+
type MockedClient = {
|
|
17
|
+
[K in keyof PromiseClient<typeof SessionService>]: MockedFunction<PromiseClient<typeof SessionService>[K]>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('createSessionRepository', () => {
|
|
21
|
+
const createMockClient = (): MockedClient => ({
|
|
22
|
+
initSession: vi.fn().mockResolvedValue({
|
|
23
|
+
sessionId: 'test-session-123',
|
|
24
|
+
deviceId: 'test-device-123',
|
|
25
|
+
needChallenge: false,
|
|
26
|
+
extra: {},
|
|
27
|
+
}),
|
|
28
|
+
challenge: vi.fn().mockResolvedValue({
|
|
29
|
+
challengeId: 'challenge-123',
|
|
30
|
+
challengeType: 1,
|
|
31
|
+
extra: { sitekey: 'test-key' },
|
|
32
|
+
challengeData: { case: undefined },
|
|
33
|
+
}),
|
|
34
|
+
verify: vi.fn().mockResolvedValue({
|
|
35
|
+
retry: false,
|
|
36
|
+
outcome: { case: 'success' as const, value: {} },
|
|
37
|
+
}),
|
|
38
|
+
updateSession: vi.fn().mockResolvedValue({}),
|
|
39
|
+
deleteSession: vi.fn().mockResolvedValue({}),
|
|
40
|
+
introspectSession: vi.fn().mockResolvedValue({}), // Required by proto but not used
|
|
41
|
+
getChallengeTypes: vi.fn().mockResolvedValue(new GetChallengeTypesResponse({ challengeTypes: [] })),
|
|
42
|
+
signout: vi.fn().mockResolvedValue(new SignoutResponse({})),
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe('session initialization behaviors', () => {
|
|
46
|
+
it('initializes a session and returns session data', async () => {
|
|
47
|
+
const mockClient = createMockClient()
|
|
48
|
+
const repository = createSessionRepository({ client: mockClient as PromiseClient<typeof SessionService> })
|
|
49
|
+
|
|
50
|
+
const result = await repository.initSession()
|
|
51
|
+
|
|
52
|
+
expect(result).toEqual({
|
|
53
|
+
sessionId: 'test-session-123',
|
|
54
|
+
deviceId: 'test-device-123',
|
|
55
|
+
needChallenge: false,
|
|
56
|
+
extra: {},
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('handles web sessions without session ID', async () => {
|
|
61
|
+
const mockClient = createMockClient()
|
|
62
|
+
mockClient.initSession.mockResolvedValue(
|
|
63
|
+
new InitSessionResponse({
|
|
64
|
+
sessionId: undefined,
|
|
65
|
+
deviceId: undefined,
|
|
66
|
+
needChallenge: true,
|
|
67
|
+
extra: { sitekey: 'turnstile-key' },
|
|
68
|
+
}),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
const repository = createSessionRepository({ client: mockClient })
|
|
72
|
+
const result = await repository.initSession()
|
|
73
|
+
|
|
74
|
+
// Web sessions don't return sessionId or deviceId (managed by cookies)
|
|
75
|
+
expect(result.sessionId).toBeUndefined()
|
|
76
|
+
expect(result.deviceId).toBeUndefined()
|
|
77
|
+
expect(result.needChallenge).toBe(true)
|
|
78
|
+
expect(result.extra).toEqual({ sitekey: 'turnstile-key' })
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('provides meaningful error when initialization fails', async () => {
|
|
82
|
+
const mockClient = createMockClient()
|
|
83
|
+
mockClient.initSession.mockRejectedValue(new Error('Network error'))
|
|
84
|
+
|
|
85
|
+
const repository = createSessionRepository({ client: mockClient })
|
|
86
|
+
|
|
87
|
+
await expect(repository.initSession()).rejects.toThrow('Failed to initialize session')
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe('bot detection behaviors', () => {
|
|
92
|
+
it('retrieves challenge information for bot detection', async () => {
|
|
93
|
+
const mockClient = createMockClient()
|
|
94
|
+
const repository = createSessionRepository({ client: mockClient as PromiseClient<typeof SessionService> })
|
|
95
|
+
|
|
96
|
+
const result = await repository.challenge({})
|
|
97
|
+
|
|
98
|
+
expect(result).toEqual({
|
|
99
|
+
challengeId: 'challenge-123',
|
|
100
|
+
challengeType: 1,
|
|
101
|
+
extra: { sitekey: 'test-key' },
|
|
102
|
+
challengeData: { case: undefined },
|
|
103
|
+
authorizeUrl: undefined,
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('throws ChallengeRejectedError when response has failure field', async () => {
|
|
108
|
+
const mockClient = createMockClient()
|
|
109
|
+
mockClient.challenge.mockResolvedValue(
|
|
110
|
+
new ChallengeResponse({
|
|
111
|
+
failure: new ChallengeFailure({ reason: ChallengeFailure_Reason.UNSPECIFIED }),
|
|
112
|
+
}),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
const repository = createSessionRepository({ client: mockClient })
|
|
116
|
+
|
|
117
|
+
await expect(repository.challenge({})).rejects.toThrow(ChallengeRejectedError)
|
|
118
|
+
await expect(repository.challenge({})).rejects.toThrow('REASON_UNSPECIFIED')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('throws ChallengeRejectedError with BOT_DETECTION_REQUIRED reason', async () => {
|
|
122
|
+
const mockClient = createMockClient()
|
|
123
|
+
mockClient.challenge.mockResolvedValue(
|
|
124
|
+
new ChallengeResponse({
|
|
125
|
+
failure: new ChallengeFailure({ reason: ChallengeFailure_Reason.BOT_DETECTION_REQUIRED }),
|
|
126
|
+
}),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
const repository = createSessionRepository({ client: mockClient })
|
|
130
|
+
|
|
131
|
+
await expect(repository.challenge({})).rejects.toThrow(ChallengeRejectedError)
|
|
132
|
+
await expect(repository.challenge({})).rejects.toThrow('REASON_BOT_DETECTION_REQUIRED')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('does NOT throw ChallengeRejectedError when no failure field is present', async () => {
|
|
136
|
+
const mockClient = createMockClient()
|
|
137
|
+
mockClient.challenge.mockResolvedValue(new ChallengeResponse({ challengeId: 'some-id', challengeType: 0 }))
|
|
138
|
+
|
|
139
|
+
const repository = createSessionRepository({ client: mockClient })
|
|
140
|
+
const result = await repository.challenge({})
|
|
141
|
+
|
|
142
|
+
expect(result.challengeId).toBe('some-id')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('ChallengeRejectedError has typed reason and rawFailure fields', async () => {
|
|
146
|
+
const mockClient = createMockClient()
|
|
147
|
+
mockClient.challenge.mockResolvedValue(
|
|
148
|
+
new ChallengeResponse({
|
|
149
|
+
failure: new ChallengeFailure({ reason: ChallengeFailure_Reason.BOT_DETECTION_REQUIRED }),
|
|
150
|
+
}),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
const repository = createSessionRepository({ client: mockClient })
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
await repository.challenge({})
|
|
157
|
+
expect.fail('Should have thrown')
|
|
158
|
+
} catch (error) {
|
|
159
|
+
expect(error).toBeInstanceOf(ChallengeRejectedError)
|
|
160
|
+
const rejected = error as ChallengeRejectedError
|
|
161
|
+
expect(rejected.reason).toBe('REASON_BOT_DETECTION_REQUIRED')
|
|
162
|
+
expect(rejected.rawFailure).toBeDefined()
|
|
163
|
+
expect(rejected.name).toBe('ChallengeRejectedError')
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('provides meaningful error when challenge request fails', async () => {
|
|
168
|
+
const mockClient = createMockClient()
|
|
169
|
+
mockClient.challenge.mockRejectedValue(new Error('API error'))
|
|
170
|
+
|
|
171
|
+
const repository = createSessionRepository({ client: mockClient })
|
|
172
|
+
|
|
173
|
+
await expect(repository.challenge({})).rejects.toThrow('Failed to get challenge')
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
describe('session verify behaviors', () => {
|
|
178
|
+
it('submits bot detection solution', async () => {
|
|
179
|
+
const mockClient = createMockClient()
|
|
180
|
+
const repository = createSessionRepository({ client: mockClient as PromiseClient<typeof SessionService> })
|
|
181
|
+
|
|
182
|
+
const result = await repository.verifySession({
|
|
183
|
+
solution: 'solution-token',
|
|
184
|
+
challengeId: 'challenge-123',
|
|
185
|
+
challengeType: ChallengeType.TURNSTILE,
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
// Verify the client was called correctly (challengeType maps to 'type' in the backend API)
|
|
189
|
+
expect(mockClient.verify).toHaveBeenCalledWith({
|
|
190
|
+
solution: 'solution-token',
|
|
191
|
+
challengeId: 'challenge-123',
|
|
192
|
+
type: ChallengeType.TURNSTILE,
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
// Should return retry status
|
|
196
|
+
expect(result).toEqual({ retry: false })
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('handles verify with additional parameters', async () => {
|
|
200
|
+
const mockClient = createMockClient()
|
|
201
|
+
const repository = createSessionRepository({ client: mockClient as PromiseClient<typeof SessionService> })
|
|
202
|
+
|
|
203
|
+
const result = await repository.verifySession({
|
|
204
|
+
solution: 'solution-token',
|
|
205
|
+
challengeId: 'challenge-123',
|
|
206
|
+
challengeType: ChallengeType.TURNSTILE,
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
// Should succeed with required params
|
|
210
|
+
expect(result).toEqual({ retry: false })
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('treats undefined outcome with retry=false as success (proto3 dropped empty VerifySuccess)', async () => {
|
|
214
|
+
const mockClient = createMockClient()
|
|
215
|
+
mockClient.verify.mockResolvedValue({
|
|
216
|
+
retry: false,
|
|
217
|
+
outcome: { case: undefined, value: undefined },
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
const repository = createSessionRepository({ client: mockClient })
|
|
221
|
+
|
|
222
|
+
const result = await repository.verifySession({
|
|
223
|
+
solution: 'solution-token',
|
|
224
|
+
challengeId: 'challenge-123',
|
|
225
|
+
challengeType: ChallengeType.TURNSTILE,
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
// Proto3 drops empty VerifySuccess — retry: false signals success
|
|
229
|
+
expect(result.retry).toBe(false)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('allows undefined outcome when retry is true (valid retry-only response)', async () => {
|
|
233
|
+
const mockClient = createMockClient()
|
|
234
|
+
mockClient.verify.mockResolvedValue({
|
|
235
|
+
retry: true,
|
|
236
|
+
outcome: { case: undefined, value: undefined },
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
const repository = createSessionRepository({ client: mockClient })
|
|
240
|
+
|
|
241
|
+
const result = await repository.verifySession({
|
|
242
|
+
solution: 'solution-token',
|
|
243
|
+
challengeId: 'challenge-123',
|
|
244
|
+
challengeType: ChallengeType.TURNSTILE,
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
expect(result.retry).toBe(true)
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('returns failure info from verify failure outcome', async () => {
|
|
251
|
+
const mockClient = createMockClient()
|
|
252
|
+
mockClient.verify.mockResolvedValue({
|
|
253
|
+
retry: true,
|
|
254
|
+
outcome: {
|
|
255
|
+
case: 'failure' as const,
|
|
256
|
+
value: {
|
|
257
|
+
reason: 1, // INVALID_SOLUTION
|
|
258
|
+
message: 'Bad code',
|
|
259
|
+
waitSeconds: 30,
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
const repository = createSessionRepository({ client: mockClient })
|
|
265
|
+
|
|
266
|
+
const result = await repository.verifySession({
|
|
267
|
+
solution: 'bad-code',
|
|
268
|
+
challengeId: 'challenge-123',
|
|
269
|
+
challengeType: ChallengeType.TURNSTILE,
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
expect(result.retry).toBe(true)
|
|
273
|
+
expect(result.failureReason).toBe('REASON_INVALID_SOLUTION')
|
|
274
|
+
expect(result.failureMessage).toBe('Bad code')
|
|
275
|
+
expect(result.waitSeconds).toBe(30)
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('provides meaningful error when verify fails', async () => {
|
|
279
|
+
const mockClient = createMockClient()
|
|
280
|
+
mockClient.verify.mockRejectedValue(new Error('Invalid solution'))
|
|
281
|
+
|
|
282
|
+
const repository = createSessionRepository({ client: mockClient })
|
|
283
|
+
|
|
284
|
+
await expect(
|
|
285
|
+
repository.verifySession({
|
|
286
|
+
solution: 'bad-token',
|
|
287
|
+
challengeId: 'challenge-123',
|
|
288
|
+
challengeType: ChallengeType.TURNSTILE,
|
|
289
|
+
}),
|
|
290
|
+
).rejects.toThrow('Failed to verify session')
|
|
291
|
+
})
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
describe('session cleanup behaviors', () => {
|
|
295
|
+
it('deletes session successfully', async () => {
|
|
296
|
+
const mockClient = createMockClient()
|
|
297
|
+
const repository = createSessionRepository({ client: mockClient as PromiseClient<typeof SessionService> })
|
|
298
|
+
|
|
299
|
+
const result = await repository.deleteSession({})
|
|
300
|
+
|
|
301
|
+
expect(result).toEqual({})
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it('provides meaningful error when deletion fails', async () => {
|
|
305
|
+
const mockClient = createMockClient()
|
|
306
|
+
mockClient.signout.mockRejectedValue(new Error('Server error'))
|
|
307
|
+
|
|
308
|
+
const repository = createSessionRepository({ client: mockClient })
|
|
309
|
+
|
|
310
|
+
await expect(repository.deleteSession({})).rejects.toThrow('Failed to delete session')
|
|
311
|
+
})
|
|
312
|
+
})
|
|
313
|
+
})
|