mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-08 18:37:46 +08:00
feat(auth): 添加 Turnstile 注册防护
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, nextTick } from 'vue'
|
||||
import RegisterDialog from '../RegisterDialog.vue'
|
||||
|
||||
const { registerMock, toastErrorMock, toastSuccessMock } = vi.hoisted(() => ({
|
||||
registerMock: vi.fn(),
|
||||
toastErrorMock: vi.fn(),
|
||||
toastSuccessMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/auth', () => ({
|
||||
authApi: {
|
||||
register: registerMock,
|
||||
sendVerificationCode: vi.fn(),
|
||||
verifyEmail: vi.fn(),
|
||||
getVerificationStatus: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useToast', () => ({
|
||||
useToast: () => ({
|
||||
success: toastSuccessMock,
|
||||
error: toastErrorMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
type TurnstileRenderOptions = {
|
||||
callback?: (token: string) => void
|
||||
'error-callback'?: () => void
|
||||
}
|
||||
|
||||
type TurnstileMock = {
|
||||
render: ReturnType<typeof vi.fn>
|
||||
execute: ReturnType<typeof vi.fn>
|
||||
reset: ReturnType<typeof vi.fn>
|
||||
remove: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
function flushPromises() {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
function installTurnstileMock(mode: 'success' | 'error'): TurnstileMock {
|
||||
let renderOptions: TurnstileRenderOptions | null = null
|
||||
const turnstile = {
|
||||
render: vi.fn((_container: HTMLElement, options: TurnstileRenderOptions) => {
|
||||
renderOptions = options
|
||||
return 'widget-id'
|
||||
}),
|
||||
execute: vi.fn(() => {
|
||||
window.queueMicrotask(() => {
|
||||
if (mode === 'success') {
|
||||
renderOptions?.callback?.('turnstile-token')
|
||||
} else {
|
||||
renderOptions?.['error-callback']?.()
|
||||
}
|
||||
})
|
||||
}),
|
||||
reset: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
}
|
||||
;(window as unknown as { turnstile: TurnstileMock }).turnstile = turnstile
|
||||
return turnstile
|
||||
}
|
||||
|
||||
async function mountRegisterDialog() {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp(RegisterDialog, {
|
||||
open: true,
|
||||
emailConfigured: false,
|
||||
requireEmailVerification: false,
|
||||
passwordPolicyLevel: 'weak',
|
||||
turnstileEnabled: true,
|
||||
turnstileSiteKey: 'site-public-key',
|
||||
})
|
||||
app.mount(root)
|
||||
await nextTick()
|
||||
return {
|
||||
app,
|
||||
root,
|
||||
unmount: () => {
|
||||
app.unmount()
|
||||
root.remove()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function fillRegistrationForm() {
|
||||
const inputs = Array.from(document.body.querySelectorAll('input'))
|
||||
const usernameInput = inputs.find((input) => input.placeholder === '请输入用户名')
|
||||
const passwordInput = inputs.find((input) => input.placeholder.includes('至少'))
|
||||
const confirmInput = inputs.find((input) => input.placeholder === '再次输入密码')
|
||||
|
||||
for (const [input, value] of [
|
||||
[usernameInput, 'alice'],
|
||||
[passwordInput, 'secret123'],
|
||||
[confirmInput, 'secret123'],
|
||||
] as const) {
|
||||
expect(input).toBeTruthy()
|
||||
input!.value = value
|
||||
input!.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
async function clickRegister() {
|
||||
const registerButton = Array.from(document.body.querySelectorAll('button')).find(
|
||||
(button) => button.textContent?.trim() === '注册'
|
||||
)
|
||||
expect(registerButton).toBeTruthy()
|
||||
expect(registerButton!.hasAttribute('disabled')).toBe(false)
|
||||
registerButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
describe('RegisterDialog Turnstile flow', () => {
|
||||
let mounted: Awaited<ReturnType<typeof mountRegisterDialog>> | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
registerMock.mockReset()
|
||||
registerMock.mockResolvedValue({ message: '注册成功' })
|
||||
toastErrorMock.mockReset()
|
||||
toastSuccessMock.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mounted?.unmount()
|
||||
mounted = null
|
||||
document.body.innerHTML = ''
|
||||
delete (window as unknown as { turnstile?: TurnstileMock }).turnstile
|
||||
delete (window as unknown as { __aetherTurnstileScriptPromise?: Promise<void> })
|
||||
.__aetherTurnstileScriptPromise
|
||||
})
|
||||
|
||||
it('gets a Turnstile token before submitting registration', async () => {
|
||||
const turnstile = installTurnstileMock('success')
|
||||
mounted = await mountRegisterDialog()
|
||||
await fillRegistrationForm()
|
||||
|
||||
await clickRegister()
|
||||
|
||||
expect(turnstile.render).toHaveBeenCalledWith(
|
||||
expect.any(HTMLElement),
|
||||
expect.objectContaining({
|
||||
sitekey: 'site-public-key',
|
||||
action: 'register',
|
||||
execution: 'execute',
|
||||
})
|
||||
)
|
||||
expect(turnstile.execute).toHaveBeenCalledWith('widget-id')
|
||||
expect(registerMock).toHaveBeenCalledWith({
|
||||
username: 'alice',
|
||||
password: 'secret123',
|
||||
turnstile_token: 'turnstile-token',
|
||||
})
|
||||
expect(turnstile.remove).toHaveBeenCalledWith('widget-id')
|
||||
})
|
||||
|
||||
it('resets Turnstile and blocks registration when verification fails', async () => {
|
||||
const turnstile = installTurnstileMock('error')
|
||||
mounted = await mountRegisterDialog()
|
||||
await fillRegistrationForm()
|
||||
|
||||
await clickRegister()
|
||||
|
||||
expect(registerMock).not.toHaveBeenCalled()
|
||||
expect(toastErrorMock).toHaveBeenCalledWith('人机验证失败,请重试', '验证失败')
|
||||
expect(turnstile.remove).toHaveBeenCalledWith('widget-id')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createApp } from 'vue'
|
||||
import TurnstileWidget from '../TurnstileWidget.vue'
|
||||
|
||||
function mountTurnstileWidget() {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp(TurnstileWidget, { siteKey: 'site-public-key' })
|
||||
const instance = app.mount(root) as unknown as {
|
||||
execute: (action: string) => Promise<string>
|
||||
}
|
||||
return {
|
||||
instance,
|
||||
unmount: () => {
|
||||
app.unmount()
|
||||
root.remove()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function turnstileScripts() {
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLScriptElement>('script[data-aether-turnstile="true"]')
|
||||
)
|
||||
}
|
||||
|
||||
describe('TurnstileWidget script loading', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
document.head.querySelectorAll('script[data-aether-turnstile="true"]').forEach((script) => {
|
||||
script.remove()
|
||||
})
|
||||
delete (window as unknown as { turnstile?: unknown }).turnstile
|
||||
delete (window as unknown as { __aetherTurnstileScriptPromise?: Promise<void> })
|
||||
.__aetherTurnstileScriptPromise
|
||||
})
|
||||
|
||||
it('retries loading the Turnstile script after a transient load failure', async () => {
|
||||
const mounted = mountTurnstileWidget()
|
||||
|
||||
const firstAttempt = mounted.instance.execute('register')
|
||||
const firstScript = turnstileScripts()[0]
|
||||
expect(firstScript).toBeTruthy()
|
||||
firstScript.dispatchEvent(new Event('error'))
|
||||
await expect(firstAttempt).rejects.toThrow('Turnstile script failed')
|
||||
|
||||
const secondAttempt = mounted.instance.execute('register')
|
||||
const scriptsAfterRetry = turnstileScripts()
|
||||
expect(scriptsAfterRetry).toHaveLength(1)
|
||||
expect(scriptsAfterRetry[0]).not.toBe(firstScript)
|
||||
scriptsAfterRetry[0].dispatchEvent(new Event('error'))
|
||||
await expect(secondAttempt).rejects.toThrow('Turnstile script failed')
|
||||
|
||||
mounted.unmount()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user