Smail/test/frontend/recovery-code-timeout.test.mjs
smail b149a0de08
Some checks are pending
Security Checks / security (push) Waiting to run
补充安全码六十秒隐藏行为回归与审查记录
2026-10-06 07:39:36 +08:00

225 lines
7.9 KiB
JavaScript

import assert from 'node:assert/strict'
import test from 'node:test'
import { readFileSync } from 'node:fs'
import { compileFunction } from 'node:vm'
import { computed, reactive, ref, watch } from './vue.mjs'
import { RECOVERY_CODE_DISPLAY_MS } from '../../frontend/src/utils/recoveryCodeDisplay.js'
// 直接执行页面逻辑并注入时钟;该回归不替代部署后的浏览器完整计时验收。
function createClock() {
let now = 0
let nextID = 0
const timers = new Map()
return {
setTimeout(callback, delay) {
const id = ++nextID
timers.set(id, { callback, deadline: now + delay })
return id
},
clearTimeout(id) { timers.delete(id) },
advance(milliseconds) {
now += milliseconds
for (const [id, timer] of timers) {
if (timer.deadline <= now) {
timers.delete(id)
timer.callback()
}
}
},
get size() { return timers.size },
}
}
async function fixture(t, kind = 'personal', role = 'user') {
const clock = createClock()
const mounted = []
const unmounted = []
const stops = []
const listeners = new Map()
const saved = []
const copied = []
let authentications = 0
let post = async () => ({ errorNo: 0, data: { codes: ['TEST-ONLY-CODE'] } })
const store = reactive({
role,
userInfos: { account: 'fixture', securitySetupRequired: true },
securityActiveSection: 'recovery',
hasCapability: () => false,
refresh: async () => {},
})
const props = reactive({ modelValue: true, user: { ID: 1, RecoveryCodeConfigured: true } })
const document = {
hidden: false,
addEventListener(name, callback) { listeners.set(name, callback) },
removeEventListener(name) { listeners.delete(name) },
}
const environment = {
computed, reactive, ref,
watch(...args) {
const stop = watch(...args)
stops.push(stop)
return stop
},
onMounted(callback) { mounted.push(callback) },
onUnmounted(callback) { unmounted.push(callback) },
http: {
get: async () => ({ errorNo: 0, data: { configured: true } }),
post: (...args) => post(...args),
},
useGlobalStatusStore: () => store,
webAuthnSupported: () => false,
lang: {},
ElNotification: () => {},
ElMessage: { success() {} },
ElMessageBox: { confirm: async () => {} },
confirmRecentAuthentication: async () => { authentications += 1 },
requestWithRecentAuthentication: action => action(),
isRecentAuthenticationCancelled: () => false,
downloadRecoveryCodes: codes => saved.push([...codes]),
RECOVERY_CODE_DISPLAY_MS,
setTimeout: clock.setTimeout,
clearTimeout: clock.clearTimeout,
clearInterval: () => {},
document,
navigator: { clipboard: { writeText: async value => copied.push(value) } },
props,
emit: (name, value) => { if (name === 'update:modelValue') props.modelValue = value },
}
const relative = kind === 'personal'
? 'SecuritySettings/SecuritySettings.js'
: 'UserManagement/RecoveryCodeDialog/RecoveryCodeDialog.js'
const source = readFileSync(new URL('../../frontend/src/views/components/' + relative, import.meta.url), 'utf8')
const code = source.replace(/^import[\s\S]*?from .*$/gm, '').replace(/^export /gm, '')
const entry = kind === 'personal' ? 'usePage()' : 'useRecoveryCodeDialog(props, emit)'
const page = compileFunction(code + '\nreturn ' + entry, Object.keys(environment))(...Object.values(environment))
let disposed = false
const dispose = () => {
if (disposed) return
disposed = true
unmounted.forEach(callback => callback())
stops.forEach(stop => stop())
}
t.after(dispose)
for (const callback of mounted) await callback()
// 管理弹窗的立即侦听会异步读取状态,等待该次微任务完成。
await new Promise(resolve => setImmediate(resolve))
return {
page, clock, store, props, saved, copied, dispose,
codes: kind === 'personal' ? page.generatedCodes : page.codes,
generate: kind === 'personal' ? page.generateRecoveryCodes : page.generateCodes,
reveal: kind === 'personal' ? page.revealRecoveryCodes : page.revealCodes,
exportCodes: kind === 'personal' ? page.saveRecoveryCodes : page.copyCodes,
get authentications() { return authentications },
setPost(callback) { post = callback },
hidePage() {
document.hidden = true
listeners.get('visibilitychange')?.()
},
}
}
for (const role of ['super_admin', 'admin', 'user']) {
for (const action of ['generate', 'reveal']) {
test('个人安全码完整显示六十秒后清除且不能继续保存:' + role + '/' + action, async t => {
const f = await fixture(t, 'personal', role)
await f[action]()
assert.equal(f.codes.value.length, 1)
f.clock.advance(59999)
assert.equal(f.codes.value.length, 1)
await f.exportCodes()
assert.equal(f.saved.length, 1)
f.clock.advance(1)
assert.equal(f.codes.value.length, 0)
await f.exportCodes()
assert.equal(f.saved.length, 1)
assert.equal(f.clock.size, 0)
assert.equal(f.page.recoveryConfigured.value, true)
})
}
}
for (const action of ['generate', 'reveal']) {
test('管理员弹窗在六十秒边界清除安全码与复制来源:' + action, async t => {
const f = await fixture(t, 'managed')
await f[action]()
assert.equal(f.codes.value.length, 1)
f.clock.advance(59999)
assert.equal(f.codes.value.length, 1)
await f.exportCodes()
assert.equal(f.copied.length, 1)
f.clock.advance(1)
assert.equal(f.codes.value.length, 0)
assert.equal(f.page.revealed.value, false)
await f.exportCodes()
assert.equal(f.copied.length, 1)
assert.equal(f.page.configured.value, true)
})
}
for (const kind of ['personal', 'managed']) {
test('重新验证查看安全码会重启计时且旧计时不会提前清除:' + kind, async t => {
const f = await fixture(t, kind)
await f.reveal()
f.clock.advance(30000)
await f.reveal()
assert.equal(f.authentications, 2)
assert.equal(f.clock.size, 1)
f.clock.advance(30000)
assert.equal(f.codes.value.length, 1)
f.clock.advance(29999)
assert.equal(f.codes.value.length, 1)
f.clock.advance(1)
assert.equal(f.codes.value.length, 0)
})
for (const action of ['hidePage', 'dispose']) {
test('页面隐藏或卸载立即清理安全码和计时:' + kind + '/' + action, async t => {
const f = await fixture(t, kind)
await f.reveal()
f[action]()
assert.equal(f.codes.value.length, 0)
assert.equal(f.clock.size, 0)
await f.exportCodes()
assert.equal(f.saved.length + f.copied.length, 0)
})
test('页面离开后晚到的响应不能重新显示安全码:' + kind + '/' + action, async t => {
const f = await fixture(t, kind)
let finish
f.setPost(() => new Promise(resolve => { finish = resolve }))
const pending = f.reveal()
await new Promise(resolve => setImmediate(resolve))
assert.equal(typeof finish, 'function')
f[action]()
finish({ errorNo: 0, data: { codes: ['LATE-TEST-CODE'] } })
await pending
assert.equal(f.codes.value.length, 0)
assert.equal(f.clock.size, 0)
})
}
}
test('切换个人设置分区立即清除安全码', async t => {
const f = await fixture(t)
await f.reveal()
f.store.securityActiveSection = 'external-email'
await Promise.resolve()
assert.equal(f.codes.value.length, 0)
assert.equal(f.clock.size, 0)
})
test('管理员关闭弹窗或切换用户立即清除安全码', async t => {
const f = await fixture(t, 'managed')
await f.reveal()
f.props.user = { ID: 2, RecoveryCodeConfigured: true }
assert.equal(f.codes.value.length, 0)
assert.equal(f.clock.size, 0)
await new Promise(resolve => setImmediate(resolve))
await f.reveal()
assert.equal(f.codes.value.length, 1)
f.page.close()
assert.equal(f.props.modelValue, false)
assert.equal(f.codes.value.length, 0)
assert.equal(f.clock.size, 0)
})