This commit is contained in:
parent
83028e2dd9
commit
3e545de8b6
@ -263,6 +263,50 @@ func TestLoginMethodsRolesAndSingleConsumption(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailLoginAfterAsyncDelivery(t *testing.T) {
|
||||
codes := make(chan string, 1)
|
||||
user, _ := setupLoginMethods(t, func(_ verification.Challenge, code string) error {
|
||||
codes <- code
|
||||
return nil
|
||||
})
|
||||
pending, err := beginLoginVerification(user, "127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := pending["challenge_id"].(string)
|
||||
input := resendLoginVerificationRequest{ID: id, Account: user.Account}
|
||||
if code, _ := callLoginHandler(t, ResendLoginVerification, input); code != 0 {
|
||||
t.Fatal("failed to start delivery")
|
||||
}
|
||||
var emailCode string
|
||||
select {
|
||||
case emailCode = <-codes:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("delivery did not run")
|
||||
}
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for {
|
||||
code, data := callLoginHandler(t, LoginVerificationStatus, input)
|
||||
if code != 0 {
|
||||
t.Fatal("status failed")
|
||||
}
|
||||
if data["delivery_status"] == "sent" {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("delivery status never became sent")
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
verify := loginVerificationRequest{ID: id, Account: user.Account, Method: "email", Code: emailCode}
|
||||
if code, _ := callLoginHandler(t, CompleteLoginVerification, verify); code != 0 {
|
||||
t.Fatal("delivered email code did not complete login")
|
||||
}
|
||||
if code, _ := callLoginHandler(t, CompleteLoginVerification, verify); code == 0 {
|
||||
t.Fatal("consumed code accepted again")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginMethodsRejectInvalidState(t *testing.T) {
|
||||
user, _ := setupLoginMethods(t, func(verification.Challenge, string) error { return nil })
|
||||
pending, _ := beginLoginVerification(user, "127.0.0.1")
|
||||
|
||||
@ -18,6 +18,7 @@ func registerV1Routes(mux *http.ServeMux) {
|
||||
registerV1MethodRoute(mux, "/api/v1/security/verification/verify", "/api/security/verification/verify", http.MethodPost, 16<<10, controllers.VerifyVerification)
|
||||
registerV1MethodRoute(mux, "/api/v1/security/login/verify", "/api/security/login/verify", http.MethodPost, 16<<10, controllers.CompleteLoginVerification)
|
||||
registerV1MethodRoute(mux, "/api/v1/security/login/resend", "/api/security/login/resend", http.MethodPost, 16<<10, controllers.ResendLoginVerification)
|
||||
registerV1MethodRoute(mux, "/api/v1/security/login/status", "/api/security/login/status", http.MethodPost, 4<<10, controllers.LoginVerificationStatus)
|
||||
registerV1MethodRoute(mux, "/api/v1/security/webauthn/registration/begin", "/api/security/webauthn/registration/begin", http.MethodPost, 16<<10, controllers.BeginWebAuthnRegistration)
|
||||
registerV1MethodRoute(mux, "/api/v1/security/webauthn/registration/finish", "/api/security/webauthn/registration/finish", http.MethodPost, 64<<10, controllers.FinishWebAuthnRegistration)
|
||||
registerV1MethodRoute(mux, "/api/v1/security/webauthn/credentials", "/api/security/webauthn/credentials", http.MethodGet, 0, controllers.ListWebAuthnCredentials)
|
||||
|
||||
@ -58,6 +58,7 @@ func TestV1PostOnlyRoutesRejectGet(t *testing.T) {
|
||||
"/api/v1/user/info",
|
||||
"/api/v1/user/list",
|
||||
"/api/v1/rule/get",
|
||||
"/api/v1/security/login/status",
|
||||
} {
|
||||
recorder := httptest.NewRecorder()
|
||||
mux.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, endpoint, nil))
|
||||
@ -198,6 +199,7 @@ func TestFrontendV1RoutesAreRegistered(t *testing.T) {
|
||||
"/api/v1/security/external-email/list",
|
||||
"/api/v1/security/external-email/verify",
|
||||
"/api/v1/security/login/resend",
|
||||
"/api/v1/security/login/status",
|
||||
"/api/v1/security/login/verify",
|
||||
"/api/v1/security/recovery/begin",
|
||||
"/api/v1/security/recovery/complete",
|
||||
|
||||
@ -26,6 +26,9 @@ export function usePage() {
|
||||
const expiresAt = computed(() => pending.value?.expiresAt || "")
|
||||
const methods = computed(() => pending.value?.methods || [])
|
||||
const sending = computed(() => pending.value?.deliveryStatus === "sending")
|
||||
const canSubmit = computed(() => !loading.value && remainingSeconds.value > 0
|
||||
&& methods.value.includes(method.value)
|
||||
&& (method.value === "totp" ? /^\d{6}$/ : /^\d{8}$/).test(code.value.trim()))
|
||||
const deliveryMessage = computed(() => {
|
||||
switch (pending.value?.deliveryStatus) {
|
||||
case "sending": return "正在发送邮件,您可以等待,也可以切换到手机动态码。"
|
||||
@ -186,6 +189,7 @@ export function usePage() {
|
||||
method,
|
||||
methods,
|
||||
sending,
|
||||
canSubmit,
|
||||
retrySeconds,
|
||||
deliveryMessage,
|
||||
chooseMethod,
|
||||
|
||||
@ -94,7 +94,7 @@
|
||||
size="large"
|
||||
class="verification-submit"
|
||||
:loading="loading"
|
||||
:disabled="remainingSeconds <= 0 || (method === 'email' && pending?.deliveryStatus !== 'sent')"
|
||||
:disabled="!canSubmit"
|
||||
@click="verifyCode"
|
||||
>
|
||||
验证并登录
|
||||
@ -125,6 +125,7 @@ const {
|
||||
method,
|
||||
methods,
|
||||
sending,
|
||||
canSubmit,
|
||||
retrySeconds,
|
||||
deliveryMessage,
|
||||
chooseMethod,
|
||||
|
||||
@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { compileFunction } from 'node:vm'
|
||||
import { computed, ref } from './vue.mjs'
|
||||
import { computed, reactive, ref } from './vue.mjs'
|
||||
|
||||
const source = readFileSync(new URL('../../frontend/src/views/LoginVerification/LoginVerification.js', import.meta.url), 'utf8')
|
||||
const createPage = compileFunction(
|
||||
@ -10,9 +10,9 @@ const createPage = compileFunction(
|
||||
['computed', 'onBeforeUnmount', 'onMounted', 'ref', 'ElMessage', 'useRouter', 'useGlobalStatusStore', 'http', 'getSafeLoginRedirect', 'window']
|
||||
)
|
||||
|
||||
function fixture() {
|
||||
function fixture({ statusFails = false } = {}) {
|
||||
const calls = []
|
||||
const pending = { challengeId: 'pending-id', account: 'alice', methods: ['email', 'totp'], deliveryStatus: 'idle', expiresAt: new Date(Date.now() + 600000).toISOString() }
|
||||
const pending = reactive({ challengeId: 'pending-id', account: 'alice', methods: ['email', 'totp'], deliveryStatus: 'idle', expiresAt: new Date(Date.now() + 600000).toISOString() })
|
||||
const store = {
|
||||
pendingLogin: pending,
|
||||
setPendingLogin: data => Object.assign(pending, { deliveryStatus: data.delivery_status, retryAt: data.retry_at }),
|
||||
@ -20,17 +20,49 @@ function fixture() {
|
||||
setUser: () => calls.push('session')
|
||||
}
|
||||
let mount
|
||||
let tick
|
||||
const page = createPage(computed, () => {}, callback => { mount = callback }, ref,
|
||||
{ error() {}, warning() {} }, () => ({ replace: async () => {} }), () => store,
|
||||
{ post: async (url, input, options) => {
|
||||
calls.push({ url, input, options })
|
||||
if (url.endsWith('/status')) {
|
||||
if (statusFails) throw new Error('status unavailable')
|
||||
return { errorNo: 0, data: { pending: true, delivery_status: 'sent' } }
|
||||
}
|
||||
if (url.endsWith('/resend')) return { errorNo: 0, data: { pending: true, delivery_status: 'sending', retry_at: new Date(Date.now() + 60000).toISOString() } }
|
||||
return { errorNo: 0, data: { account: 'alice' } }
|
||||
} }, () => '/', { setInterval: () => 1, clearInterval() {} })
|
||||
} }, () => '/', { setInterval: callback => { tick = callback; return 1 }, clearInterval() {} })
|
||||
mount()
|
||||
return { page, calls }
|
||||
return { page, calls, tick: async () => { tick(); await new Promise(resolve => setImmediate(resolve)) } }
|
||||
}
|
||||
|
||||
test('email code remains submittable when delivery status polling fails', async () => {
|
||||
const { page, calls, tick } = fixture({ statusFails: true })
|
||||
page.chooseMethod('email')
|
||||
assert.equal(page.canSubmit.value, false)
|
||||
await page.resendCode()
|
||||
await tick()
|
||||
assert.ok(page.errorMessage.value)
|
||||
assert.equal(page.sending.value, true)
|
||||
page.code.value = '12345678'
|
||||
assert.equal(page.canSubmit.value, true)
|
||||
await page.verifyCode()
|
||||
assert.equal(calls.filter(call => call.url?.endsWith('/resend')).length, 1)
|
||||
assert.ok(calls.includes('session'))
|
||||
assert.equal(calls.find(call => call.url?.endsWith('/verify')).input.method, 'email')
|
||||
})
|
||||
|
||||
test('polling advances email delivery to sent without sending again', async () => {
|
||||
const { page, calls, tick } = fixture()
|
||||
page.chooseMethod('email')
|
||||
await page.resendCode()
|
||||
await tick()
|
||||
assert.equal(page.sending.value, false)
|
||||
assert.equal(page.pending.value.deliveryStatus, 'sent')
|
||||
assert.equal(page.errorMessage.value, '')
|
||||
assert.equal(calls.filter(call => call.url?.endsWith('/resend')).length, 1)
|
||||
})
|
||||
|
||||
test('进入选择页和切换验证方式均不发信,只有点击发送按钮才请求一次', async () => {
|
||||
const { page, calls } = fixture()
|
||||
assert.deepEqual(calls, [])
|
||||
|
||||
Loading…
Reference in New Issue
Block a user