This commit is contained in:
parent
4e2f69ae60
commit
914bdc67e2
@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.pcux.cn/xuans/Smail/config"
|
||||
"git.pcux.cn/xuans/Smail/db"
|
||||
"git.pcux.cn/xuans/Smail/dto/response"
|
||||
"git.pcux.cn/xuans/Smail/i18n"
|
||||
"git.pcux.cn/xuans/Smail/models"
|
||||
@ -104,19 +105,28 @@ func loginSecurityFields(user *models.User) map[string]any {
|
||||
fields := map[string]any{
|
||||
"recoveryCodesConfigured": false,
|
||||
"showRecoverySetupPrompt": false,
|
||||
"showSecuritySetupPrompt": user != nil && user.SecuritySetupAcknowledged == 0,
|
||||
"showSecuritySetupPrompt": user != nil,
|
||||
"externalEmailConfigured": false,
|
||||
"webAuthnEnabled": webAuthnRuntime() != nil,
|
||||
}
|
||||
if user == nil || user.EffectiveRole() == access.RoleSuperAdmin {
|
||||
if user == nil {
|
||||
return fields
|
||||
}
|
||||
|
||||
configured := false
|
||||
if service := credentialRuntime(); service != nil {
|
||||
configured, _ = service.HasValidRecoveryCode(user.ID)
|
||||
if db.Instance != nil && user.ID > 0 {
|
||||
emailConfigured, err := db.Instance.Where("user_id = ? AND status = ?", user.ID, models.ExternalEmailStatusVerified).Exist(&models.ExternalEmail{})
|
||||
if err == nil {
|
||||
fields["externalEmailConfigured"] = emailConfigured
|
||||
}
|
||||
}
|
||||
fields["recoveryCodesConfigured"] = configured
|
||||
fields["showRecoverySetupPrompt"] = !configured
|
||||
configured := user.EffectiveRole() == access.RoleSuperAdmin
|
||||
if !configured {
|
||||
if service := credentialRuntime(); service != nil {
|
||||
configured, _ = service.HasValidRecoveryCode(user.ID)
|
||||
}
|
||||
fields["recoveryCodesConfigured"] = configured
|
||||
fields["showRecoverySetupPrompt"] = !configured
|
||||
}
|
||||
fields["showSecuritySetupPrompt"] = !configured || fields["externalEmailConfigured"] != true
|
||||
return fields
|
||||
}
|
||||
|
||||
|
||||
@ -6,10 +6,11 @@ import (
|
||||
"git.pcux.cn/xuans/Smail/db"
|
||||
"git.pcux.cn/xuans/Smail/dto/response"
|
||||
"git.pcux.cn/xuans/Smail/models"
|
||||
"git.pcux.cn/xuans/Smail/session"
|
||||
"git.pcux.cn/xuans/Smail/utils/context"
|
||||
)
|
||||
|
||||
// AcknowledgeSecuritySetup 仅确认当前账号已经查看引导,不代表完成安全设置。
|
||||
// AcknowledgeSecuritySetup 仅关闭当前登录会话的提醒,下次登录重新检查设置。
|
||||
func AcknowledgeSecuritySetup(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
|
||||
if ctx == nil || ctx.UserID <= 0 {
|
||||
writeSecurityError(w, response.NeedLogin, "请先登录")
|
||||
@ -25,12 +26,10 @@ func AcknowledgeSecuritySetup(ctx *context.Context, w http.ResponseWriter, req *
|
||||
writeSecurityError(w, response.NeedLogin, "请重新登录")
|
||||
return
|
||||
}
|
||||
if user.SecuritySetupAcknowledged == 0 {
|
||||
user.SecuritySetupAcknowledged = 1
|
||||
if _, err := db.Instance.ID(ctx.UserID).Cols("security_setup_acknowledged").Update(&user); err != nil {
|
||||
writeSecurityError(w, response.ServerError, "保存安全引导状态失败,请重试")
|
||||
return
|
||||
}
|
||||
if session.Instance == nil || req == nil {
|
||||
writeSecurityError(w, response.ServerError, "保存提醒状态失败,请重试")
|
||||
return
|
||||
}
|
||||
session.Instance.Put(req.Context(), session.SecuritySetupDismissedKey, true)
|
||||
response.NewSuccessResponse(map[string]any{"showSecuritySetupPrompt": false}).FPrint(w)
|
||||
}
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.pcux.cn/xuans/Smail/config"
|
||||
"git.pcux.cn/xuans/Smail/db"
|
||||
@ -51,7 +53,8 @@ func TestLogoutDestroysSessionAndCSRFCookies(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecuritySetupAcknowledgementIsPersistentAndAccountScoped(t *testing.T) {
|
||||
func setupSecurityPromptTest(t *testing.T) *xorm.Engine {
|
||||
t.Helper()
|
||||
engine, err := xorm.NewEngine("sqlite", filepath.Join(t.TempDir(), "onboarding.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@ -59,9 +62,25 @@ func TestSecuritySetupAcknowledgementIsPersistentAndAccountScoped(t *testing.T)
|
||||
previous := db.Instance
|
||||
db.Instance = engine
|
||||
t.Cleanup(func() { db.Instance = previous; _ = engine.Close() })
|
||||
if err := engine.Sync2(&models.User{}); err != nil {
|
||||
if err := engine.Sync2(&models.User{}, &models.ExternalEmail{}, &models.RecoveryCode{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
previousRuntime := securityRuntime
|
||||
securityRuntime.credentials = nil
|
||||
t.Cleanup(func() { securityRuntime = previousRuntime })
|
||||
return engine
|
||||
}
|
||||
|
||||
func TestSecuritySetupAcknowledgementIsSessionScoped(t *testing.T) {
|
||||
engine := setupSecurityPromptTest(t)
|
||||
previousSession, previousConfig, previousManager := session.Instance, config.Instance, config.CurrentManager()
|
||||
session.Instance = scs.New()
|
||||
config.SetRuntimeManager(nil)
|
||||
config.Instance = &config.Config{Domain: "example.com"}
|
||||
t.Cleanup(func() {
|
||||
session.Instance, config.Instance = previousSession, previousConfig
|
||||
config.SetRuntimeManager(previousManager)
|
||||
})
|
||||
first := models.User{Account: "root", Name: "Root", Role: string(access.RoleSuperAdmin)}
|
||||
second := models.User{Account: "alice", Name: "Alice", Role: string(access.RoleUser)}
|
||||
if _, err := engine.Insert(&first, &second); err != nil {
|
||||
@ -70,10 +89,18 @@ func TestSecuritySetupAcknowledgementIsPersistentAndAccountScoped(t *testing.T)
|
||||
if loginSecurityFields(&first)["showSecuritySetupPrompt"] != true {
|
||||
t.Fatal("first super administrator login must show security setup")
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/security/setup/acknowledge", strings.NewReader(`{"user_id":2}`))
|
||||
loadedSession, err := session.Instance.Load(request.Context(), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request = request.WithContext(loadedSession)
|
||||
if err := session.PutUser(loadedSession, &first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
recorder := httptest.NewRecorder()
|
||||
AcknowledgeSecuritySetup(&appcontext.Context{UserID: first.ID}, recorder,
|
||||
httptest.NewRequest(http.MethodPost, "/api/v1/security/setup/acknowledge", strings.NewReader(`{"user_id":2}`)))
|
||||
AcknowledgeSecuritySetup(&appcontext.Context{UserID: first.ID}, recorder, request)
|
||||
var payload struct {
|
||||
ErrorNo int `json:"errorNo"`
|
||||
}
|
||||
@ -88,16 +115,87 @@ func TestSecuritySetupAcknowledgementIsPersistentAndAccountScoped(t *testing.T)
|
||||
if _, err := engine.ID(second.ID).Get(&untouched); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.SecuritySetupAcknowledged != 1 || untouched.SecuritySetupAcknowledged != 0 {
|
||||
t.Fatal("acknowledgement changed the wrong account")
|
||||
if loaded.SecuritySetupAcknowledged != 0 || untouched.SecuritySetupAcknowledged != 0 {
|
||||
t.Fatal("dismissal must not persist on either account")
|
||||
}
|
||||
fields := loginSecurityFields(&loaded)
|
||||
if fields["showSecuritySetupPrompt"] != false || fields["showRecoverySetupPrompt"] != false {
|
||||
t.Fatal("acknowledged super administrator must not receive either prompt")
|
||||
if fields["showSecuritySetupPrompt"] != true || fields["showRecoverySetupPrompt"] != false {
|
||||
t.Fatal("incomplete setup must be reported again on login")
|
||||
}
|
||||
if untouched.Password != second.Password || loaded.SecurityVersion != first.SecurityVersion {
|
||||
t.Fatal("acknowledgement changed security credentials")
|
||||
}
|
||||
otherSession, err := session.Instance.Load(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if session.Instance.GetBool(otherSession, session.SecuritySetupDismissedKey) {
|
||||
t.Fatal("dismissal leaked to another session")
|
||||
}
|
||||
token, _, err := session.Instance.Commit(loadedSession)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
refreshed, err := session.Instance.Load(context.Background(), token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertInfoPrompt := func(want bool) {
|
||||
t.Helper()
|
||||
recorder := httptest.NewRecorder()
|
||||
Info(&appcontext.Context{UserID: first.ID, Role: access.RoleSuperAdmin}, recorder, request.WithContext(refreshed))
|
||||
var payload struct {
|
||||
ErrorNo int `json:"errorNo"`
|
||||
Data struct {
|
||||
Show bool `json:"showSecuritySetupPrompt"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil || payload.ErrorNo != 0 || payload.Data.Show != want {
|
||||
t.Fatalf("unexpected refreshed prompt: %s", recorder.Body.String())
|
||||
}
|
||||
}
|
||||
assertInfoPrompt(false)
|
||||
if err := session.PutUser(refreshed, &first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertInfoPrompt(true)
|
||||
}
|
||||
|
||||
func TestSecuritySetupRequiresUsableCredentials(t *testing.T) {
|
||||
for _, role := range []access.Role{access.RoleUser, access.RoleAdmin, access.RoleSuperAdmin} {
|
||||
for _, emailStatus := range []string{"", models.ExternalEmailStatusPending, models.ExternalEmailStatusVerified, models.ExternalEmailStatusDisabled} {
|
||||
for _, codeStatus := range []string{"missing", "valid", "used"} {
|
||||
t.Run(string(role)+"/"+emailStatus+"/"+codeStatus, func(t *testing.T) {
|
||||
engine := setupSecurityPromptTest(t)
|
||||
user := models.User{Account: "alice", Role: string(role), SecuritySetupAcknowledged: 1}
|
||||
if _, err := engine.Insert(&user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if emailStatus != "" {
|
||||
if _, err := engine.Insert(&models.ExternalEmail{UserID: user.ID, Status: emailStatus, EmailFingerprint: "fixture"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if codeStatus != "missing" {
|
||||
code := models.RecoveryCode{UserID: user.ID, CodeHash: "fixture"}
|
||||
if codeStatus == "used" {
|
||||
now := time.Now()
|
||||
code.UsedAt = &now
|
||||
}
|
||||
if _, err := engine.Insert(&code); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
fields := loginSecurityFields(&user)
|
||||
emailReady := emailStatus == models.ExternalEmailStatusVerified
|
||||
codeReady := codeStatus == "valid" || role == access.RoleSuperAdmin
|
||||
if fields["externalEmailConfigured"] != emailReady || fields["showSecuritySetupPrompt"] != (!emailReady || !codeReady) {
|
||||
t.Fatalf("unexpected security state: %#v", fields)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecuritySetupRejectsAnonymous(t *testing.T) {
|
||||
|
||||
@ -9,6 +9,7 @@ import (
|
||||
"git.pcux.cn/xuans/Smail/models"
|
||||
"git.pcux.cn/xuans/Smail/services/access"
|
||||
"git.pcux.cn/xuans/Smail/services/auth"
|
||||
"git.pcux.cn/xuans/Smail/session"
|
||||
"git.pcux.cn/xuans/Smail/utils/array"
|
||||
"git.pcux.cn/xuans/Smail/utils/context"
|
||||
"git.pcux.cn/xuans/Smail/utils/csrf"
|
||||
@ -226,22 +227,20 @@ func Info(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
|
||||
domains := array.Difference(mailDomains.Accepted, []string{mailDomains.Primary})
|
||||
domains = append([]string{mailDomains.Primary}, domains...)
|
||||
result := map[string]any{
|
||||
"id": ctx.UserID,
|
||||
"account": ctx.UserAccount,
|
||||
"name": ctx.UserName,
|
||||
"role": ctx.EffectiveRole(),
|
||||
"capabilities": access.Capabilities(ctx.EffectiveRole()),
|
||||
"is_admin": ctx.IsAdmin,
|
||||
"must_change_password": ctx.MustChangePassword,
|
||||
"showSecuritySetupPrompt": ctx.SecuritySetupAcknowledged == 0,
|
||||
"webAuthnEnabled": webAuthnRuntime() != nil,
|
||||
"domains": domains,
|
||||
"id": ctx.UserID,
|
||||
"account": ctx.UserAccount,
|
||||
"name": ctx.UserName,
|
||||
"role": ctx.EffectiveRole(),
|
||||
"capabilities": access.Capabilities(ctx.EffectiveRole()),
|
||||
"is_admin": ctx.IsAdmin,
|
||||
"must_change_password": ctx.MustChangePassword,
|
||||
"domains": domains,
|
||||
}
|
||||
if ctx.EffectiveRole() != access.RoleSuperAdmin {
|
||||
result["recoveryCodesConfigured"] = ctx.RecoveryCodeConfigured
|
||||
result["showRecoverySetupPrompt"] = !ctx.RecoveryCodeConfigured
|
||||
} else {
|
||||
result["recoveryCodesConfigured"] = false
|
||||
for key, value := range loginSecurityFields(&models.User{ID: ctx.UserID, Role: string(ctx.EffectiveRole())}) {
|
||||
result[key] = value
|
||||
}
|
||||
if session.Instance != nil && session.Instance.GetBool(req.Context(), session.SecuritySetupDismissedKey) {
|
||||
result["showSecuritySetupPrompt"] = false
|
||||
result["showRecoverySetupPrompt"] = false
|
||||
}
|
||||
response.NewSuccessResponse(result).FPrint(w)
|
||||
|
||||
@ -11,6 +11,7 @@ import (
|
||||
)
|
||||
|
||||
const userSessionKey = "user"
|
||||
const SecuritySetupDismissedKey = "security_setup_dismissed"
|
||||
const recentAuthenticationLifetime = 10 * time.Minute
|
||||
|
||||
type UserIdentity struct {
|
||||
@ -55,6 +56,7 @@ func PutUser(ctx context.Context, user *models.User) error {
|
||||
return err
|
||||
}
|
||||
Instance.Put(ctx, userSessionKey, string(encoded))
|
||||
Instance.Remove(ctx, SecuritySetupDismissedKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@ -19,9 +19,20 @@ func TestUserIdentityRoundTrip(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user := &models.User{ID: 7, Account: "admin", Name: "Admin", IsAdmin: 1, SessionVersion: 4, SecurityVersion: 3, MustChangePassword: 1}
|
||||
Instance.Put(loaded, SecuritySetupDismissedKey, true)
|
||||
if err := PutUser(loaded, user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if Instance.GetBool(loaded, SecuritySetupDismissedKey) {
|
||||
t.Fatal("new login retained the previous dismissal")
|
||||
}
|
||||
Instance.Put(loaded, SecuritySetupDismissedKey, true)
|
||||
if err := RefreshRecentAuthentication(loaded, user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !Instance.GetBool(loaded, SecuritySetupDismissedKey) {
|
||||
t.Fatal("recent authentication cleared the current dismissal")
|
||||
}
|
||||
identity, err := GetUser(loaded)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@ -22,7 +22,7 @@ export function usePage() {
|
||||
options.push({ value: 'password', label: lang.modify_pwd })
|
||||
options.push({ value: 'question', label: '密保问题' })
|
||||
if (!isSuperAdmin.value) {
|
||||
options.push({ value: 'recovery', label: '离线恢复码' })
|
||||
options.push({ value: 'recovery', label: '安全码' })
|
||||
}
|
||||
options.push({ value: 'external-email', label: '外部安全邮箱' })
|
||||
options.push({ value: 'totp', label: '手机动态码' })
|
||||
|
||||
@ -84,7 +84,7 @@
|
||||
<el-divider v-if="selectedSection === 'recovery'" class="settings-divider" />
|
||||
<section v-if="selectedSection === 'recovery' && !isSuperAdmin" class="security-section">
|
||||
<div class="settings-header security-key-header">
|
||||
<div><h3>离线恢复码</h3><p class="settings-desc">恢复码只在生成时显示一次,请保存到安全位置。</p></div>
|
||||
<div><h3>安全码</h3><p class="settings-desc">安全码只在生成时显示一次,请妥善保存。</p></div>
|
||||
<div class="section-actions"><el-button type="primary" @click="generateRecoveryCodes">生成新恢复码</el-button><el-button v-if="recoveryConfigured" type="danger" plain @click="revokeRecoveryCodes">撤销</el-button></div>
|
||||
</div>
|
||||
<el-alert v-if="recoveryConfigured" title="当前已配置有效离线恢复码" type="success" :closable="false" show-icon />
|
||||
|
||||
@ -10,16 +10,16 @@ export function usePage() {
|
||||
&& globalStatus.userInfos.showSecuritySetupPrompt === true
|
||||
&& !globalStatus.userInfos.must_change_password)
|
||||
const isSuperAdmin = computed(() => globalStatus.role === 'super_admin')
|
||||
const options = computed(() => [
|
||||
{ key: 'question', title: '密保问题', description: '设置只有自己知道的答案,避免使用容易猜到的个人信息。' },
|
||||
{ key: 'external-email', title: '外部安全邮箱', description: '绑定可正常接收验证码的外部邮箱,并完成验证。' },
|
||||
{
|
||||
key: 'webauthn', title: '安全密钥',
|
||||
description: globalStatus.userInfos.webAuthnEnabled === true
|
||||
? '使用支持 WebAuthn 的设备或浏览器注册安全密钥。'
|
||||
: '服务器尚未启用,需由部署管理员配置 WebAuthn 后才能注册。'
|
||||
const options = computed(() => {
|
||||
const items = []
|
||||
if (!isSuperAdmin.value && !globalStatus.userInfos.recoveryCodesConfigured) {
|
||||
items.push({ key: 'recovery', title: '安全码', description: '生成安全码并妥善保存。' })
|
||||
}
|
||||
])
|
||||
if (!globalStatus.userInfos.externalEmailConfigured) {
|
||||
items.push({ key: 'external-email', title: '外部邮箱', description: '绑定邮箱并完成验证。' })
|
||||
}
|
||||
return items
|
||||
})
|
||||
|
||||
const acknowledge = async section => {
|
||||
if (saving.value) return
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
:show-close="false"
|
||||
class="security-setup-dialog"
|
||||
>
|
||||
<p class="setup-description">欢迎使用 Smail!首次登录请了解并设置以下安全选项,确认后不再自动弹出此引导。</p>
|
||||
<p class="setup-description">{{ isSuperAdmin ? '请绑定外部邮箱。未完成前,每次登录都会提醒。' : '请设置安全码并绑定外部邮箱。未完成前,每次登录都会提醒。' }}</p>
|
||||
<div class="setup-options">
|
||||
<section v-for="item in options" :key="item.key" class="setup-option">
|
||||
<div>
|
||||
@ -18,24 +18,10 @@
|
||||
<el-button :disabled="saving" @click="acknowledge(item.key)">前往设置</el-button>
|
||||
</section>
|
||||
</div>
|
||||
<el-alert
|
||||
v-if="isSuperAdmin"
|
||||
title="超级管理员密码仍只能通过部署端受保护的本机通道重置,上述选项不会开放网页密码恢复。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
<el-alert
|
||||
v-else
|
||||
title="普通账号还可在安全设置中生成离线恢复码。恢复码只显示一次,请妥善保存。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
|
||||
<template #footer>
|
||||
<el-button :loading="saving" @click="acknowledge()">我已了解,稍后设置</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="acknowledge('question')">开始设置</el-button>
|
||||
<el-button :loading="saving" @click="acknowledge()">我知道了</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="acknowledge(options[0]?.key)">去设置</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
@ -21,17 +21,17 @@ function fixture(post = async () => ({ errorNo: 0 })) {
|
||||
return { store, page: createPage(computed, ref, { post }, () => store) }
|
||||
}
|
||||
|
||||
test('初始化超级管理员首次登录显示三项引导,强制改密时延后', () => {
|
||||
test('超级管理员只提醒邮箱,强制改密时延后', () => {
|
||||
const { store, page } = fixture()
|
||||
assert.equal(page.visible.value, true)
|
||||
assert.deepEqual(Array.from(page.options.value, item => item.key), ['question', 'external-email', 'webauthn'])
|
||||
assert.deepEqual(Array.from(page.options.value, item => item.key), ['external-email'])
|
||||
store.userInfos.must_change_password = 1
|
||||
assert.equal(page.visible.value, false)
|
||||
store.userInfos.must_change_password = 0
|
||||
assert.equal(page.visible.value, true)
|
||||
})
|
||||
|
||||
test('确认成功后关闭并打开目标设置,刷新后的持久化状态不再弹出', async () => {
|
||||
test('确认仅关闭本次提醒,下次登录未完成设置仍提醒', async () => {
|
||||
const requests = []
|
||||
const { store, page } = fixture(async path => { requests.push(path); return { errorNo: 0 } })
|
||||
await page.acknowledge('external-email')
|
||||
@ -42,6 +42,38 @@ test('确认成功后关闭并打开目标设置,刷新后的持久化状态
|
||||
assert.deepEqual(requests, ['/api/v1/security/setup/acknowledge'])
|
||||
store.userInfos = { account: 'root', showSecuritySetupPrompt: false }
|
||||
assert.equal(page.visible.value, false)
|
||||
store.settingsDrawerVisible = false
|
||||
store.userInfos = { account: 'root', showSecuritySetupPrompt: true }
|
||||
assert.equal(page.visible.value, true)
|
||||
})
|
||||
|
||||
test('管理员和普通用户只显示尚未完成的安全码与邮箱', () => {
|
||||
for (const role of ['admin', 'user']) {
|
||||
const { store, page } = fixture()
|
||||
store.role = role
|
||||
assert.deepEqual(Array.from(page.options.value, item => item.key), ['recovery', 'external-email'])
|
||||
store.userInfos.recoveryCodesConfigured = true
|
||||
assert.deepEqual(Array.from(page.options.value, item => item.key), ['external-email'])
|
||||
store.userInfos.externalEmailConfigured = true
|
||||
store.userInfos.recoveryCodesConfigured = false
|
||||
assert.deepEqual(Array.from(page.options.value, item => item.key), ['recovery'])
|
||||
store.userInfos.recoveryCodesConfigured = true
|
||||
store.userInfos.showSecuritySetupPrompt = false
|
||||
assert.deepEqual(Array.from(page.options.value, item => item.key), [])
|
||||
assert.equal(page.visible.value, false)
|
||||
}
|
||||
})
|
||||
|
||||
test('我知道了只关闭弹窗,去设置打开第一个未完成项', async () => {
|
||||
const { store, page } = fixture()
|
||||
store.role = 'user'
|
||||
await page.acknowledge()
|
||||
assert.equal(page.visible.value, false)
|
||||
assert.equal(store.settingsDrawerVisible, false)
|
||||
store.userInfos.showSecuritySetupPrompt = true
|
||||
await page.acknowledge(page.options.value[0].key)
|
||||
assert.equal(store.securityActiveSection, 'recovery')
|
||||
assert.equal(store.settingsDrawerVisible, true)
|
||||
})
|
||||
|
||||
test('保存失败保留引导,可重试且不将查看状态误记为设置完成', async () => {
|
||||
@ -58,8 +90,8 @@ test('并发点击只提交一次,切换账号后不修改新账号状态', as
|
||||
let finish
|
||||
let calls = 0
|
||||
const { store, page } = fixture(() => { calls++; return new Promise(resolve => { finish = resolve }) })
|
||||
const pending = page.acknowledge('question')
|
||||
await page.acknowledge('webauthn')
|
||||
const pending = page.acknowledge('recovery')
|
||||
await page.acknowledge('external-email')
|
||||
assert.equal(calls, 1)
|
||||
store.userInfos = { account: 'alice', showSecuritySetupPrompt: true }
|
||||
finish({ errorNo: 0 })
|
||||
|
||||
Loading…
Reference in New Issue
Block a user