This commit is contained in:
parent
306af4b7b0
commit
998b68079a
@ -93,6 +93,30 @@ func TestBindExternalEmailReturnsStableRecentAuthenticationCode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangeOwnExternalEmailRequiresRecentAuthentication(t *testing.T) {
|
||||
previous := securityRuntime
|
||||
securityRuntime.external = &externalemail.Repository{}
|
||||
securityRuntime.verification = &verification.Service{}
|
||||
t.Cleanup(func() { securityRuntime = previous })
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/security/external-email/change",
|
||||
strings.NewReader(`{"email":"backup@example.com","type":"primary"}`),
|
||||
)
|
||||
ChangeOwnExternalEmail(&appcontext.Context{UserID: 1, UserAccount: "alice"}, recorder, request)
|
||||
|
||||
var result response.Response
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("invalid JSON response: %v", err)
|
||||
}
|
||||
data, ok := result.Data.(map[string]any)
|
||||
if result.ErrorNo != response.NoAccessPrivileges || !ok || data["code"] != string(access.CodeRecentAuthRequired) {
|
||||
t.Fatalf("recent authentication response = %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtocolCredentialHandlersManageCurrentUserCredential(t *testing.T) {
|
||||
_, _, engine, user, _ := newPublicSecurityFixture(t)
|
||||
credentialService, err := credentials.NewService(engine)
|
||||
|
||||
@ -451,3 +451,154 @@ func TestManagedExternalEmailChangeInvalidatesPreviousChallenge(t *testing.T) {
|
||||
t.Fatalf("new challenge was not valid: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnExternalEmailChangeReplacesOnlyAfterSuccessfulVerification(t *testing.T) {
|
||||
oldConfig := config.Instance
|
||||
oldDB := db.Instance
|
||||
oldRuntime := currentSecurityRuntime()
|
||||
testRoot := t.TempDir()
|
||||
t.Cleanup(func() {
|
||||
if db.Instance != nil && db.Instance != oldDB {
|
||||
_ = db.Instance.Close()
|
||||
}
|
||||
config.Instance = oldConfig
|
||||
db.Instance = oldDB
|
||||
ConfigureSecurityRuntime(oldRuntime.verification, oldRuntime.external, oldRuntime.recovery, oldRuntime.credentials)
|
||||
ConfigureWebAuthnRuntime(oldRuntime.webauthn)
|
||||
})
|
||||
|
||||
config.Instance = &config.Config{DbType: config.DBTypeSQLite, DbDSN: filepath.Join(testRoot, "own-external-email.db")}
|
||||
if err := db.Init(""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user := models.User{Account: "email-self", Name: "Email Self", SessionVersion: 1, SecurityVersion: 1}
|
||||
user.ApplyRole(access.RoleUser)
|
||||
if err := auth.SetPassword(&user, "UserPass123"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Instance.Insert(&user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
vault := externalemail.NewVaultWithKeys(
|
||||
[]byte("01234567890123456789012345678901"),
|
||||
[]byte("abcdefabcdefabcdefabcdefabcdefab"),
|
||||
)
|
||||
external, err := externalemail.NewRepository(db.Instance, vault)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldBinding, err := external.Bind(externalemail.BindRequest{
|
||||
UserID: user.ID,
|
||||
Email: "old@example.com",
|
||||
Type: models.ExternalEmailTypePrimary,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := external.Verify(user.ID, oldBinding.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store, err := verification.NewStore(32)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
issuedCodes := make(map[string]string)
|
||||
verificationService, err := verification.NewService(store, func(challenge verification.Challenge, code string) error {
|
||||
issuedCodes[challenge.ID] = code
|
||||
return nil
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ConfigureSecurityRuntime(verificationService, external, nil, nil)
|
||||
|
||||
ctx := &appcontext.Context{
|
||||
UserID: user.ID,
|
||||
UserAccount: user.Account,
|
||||
Role: access.RoleUser,
|
||||
RecentAuth: true,
|
||||
}
|
||||
changeRecorder := httptest.NewRecorder()
|
||||
changeRequest := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/security/external-email/change",
|
||||
strings.NewReader(`{"email":"new@example.com","type":"primary"}`),
|
||||
)
|
||||
ChangeOwnExternalEmail(ctx, changeRecorder, changeRequest)
|
||||
var changed struct {
|
||||
ErrorNo int `json:"errorNo"`
|
||||
Data struct {
|
||||
BindingID int `json:"binding_id"`
|
||||
ChallengeID string `json:"challenge_id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(changeRecorder.Body.Bytes(), &changed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if changed.ErrorNo != 0 || changed.Data.BindingID <= 0 || changed.Data.ChallengeID == "" {
|
||||
t.Fatalf("unexpected own external email response: %s", changeRecorder.Body.String())
|
||||
}
|
||||
|
||||
assertExternalEmailStatus(t, external, user.ID, "old@example.com", models.ExternalEmailStatusVerified)
|
||||
assertExternalEmailStatus(t, external, user.ID, "new@example.com", models.ExternalEmailStatusPending)
|
||||
|
||||
wrongRecorder := httptest.NewRecorder()
|
||||
wrongRequest := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/security/external-email/change/verify",
|
||||
strings.NewReader(`{"id":"`+changed.Data.ChallengeID+`","binding_id":`+itoa(changed.Data.BindingID)+`,"email":"new@example.com","code":"00000000"}`),
|
||||
)
|
||||
VerifyOwnExternalEmail(ctx, wrongRecorder, wrongRequest)
|
||||
var wrongResult struct {
|
||||
ErrorNo int `json:"errorNo"`
|
||||
}
|
||||
if err := json.Unmarshal(wrongRecorder.Body.Bytes(), &wrongResult); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if wrongResult.ErrorNo == 0 {
|
||||
t.Fatalf("wrong verification code was accepted: %s", wrongRecorder.Body.String())
|
||||
}
|
||||
assertExternalEmailStatus(t, external, user.ID, "old@example.com", models.ExternalEmailStatusVerified)
|
||||
assertExternalEmailStatus(t, external, user.ID, "new@example.com", models.ExternalEmailStatusPending)
|
||||
|
||||
verifyRecorder := httptest.NewRecorder()
|
||||
verifyRequest := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/security/external-email/change/verify",
|
||||
strings.NewReader(`{"id":"`+changed.Data.ChallengeID+`","binding_id":`+itoa(changed.Data.BindingID)+`,"email":"new@example.com","code":"`+issuedCodes[changed.Data.ChallengeID]+`"}`),
|
||||
)
|
||||
VerifyOwnExternalEmail(ctx, verifyRecorder, verifyRequest)
|
||||
var verified struct {
|
||||
ErrorNo int `json:"errorNo"`
|
||||
}
|
||||
if err := json.Unmarshal(verifyRecorder.Body.Bytes(), &verified); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if verified.ErrorNo != 0 {
|
||||
t.Fatalf("valid verification code failed: %s", verifyRecorder.Body.String())
|
||||
}
|
||||
assertExternalEmailStatus(t, external, user.ID, "old@example.com", models.ExternalEmailStatusDisabled)
|
||||
assertExternalEmailStatus(t, external, user.ID, "new@example.com", models.ExternalEmailStatusVerified)
|
||||
}
|
||||
|
||||
func assertExternalEmailStatus(t *testing.T, repository *externalemail.Repository, userID int, email, status string) {
|
||||
t.Helper()
|
||||
items, err := repository.List(userID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, item := range items {
|
||||
decrypted, decryptErr := repository.Decrypt(item)
|
||||
if decryptErr != nil {
|
||||
t.Fatal(decryptErr)
|
||||
}
|
||||
if decrypted == email {
|
||||
if item.Status != status {
|
||||
t.Fatalf("external email %s status = %s, want %s", email, item.Status, status)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("external email %s was not found", email)
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
:title="selfManaged ? '编辑我的外部邮箱' : '编辑外部邮箱'"
|
||||
:title="props.selfManaged ? '编辑我的外部邮箱' : '编辑外部邮箱'"
|
||||
width="min(520px, calc(100vw - 32px))"
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="!busy"
|
||||
|
||||
@ -14,7 +14,14 @@ function loadStatusHelpers() {
|
||||
.replace(/^import .*$/gm, '')
|
||||
.replace(/export \{[\s\S]*?\}\s*$/, '')
|
||||
.replaceAll('export ', '')
|
||||
return compileFunction(`${source}\nreturn { roleMeta, recoveryCodeMeta, externalEmailMeta, accountStatusMeta }`)()
|
||||
return compileFunction(`${source}\nreturn { roleMeta, recoveryCodeMeta, externalEmailMeta, accountStatusMeta, isSameUser, canManageRecoveryCode }`)()
|
||||
}
|
||||
|
||||
function loadExternalEmailHelpers() {
|
||||
const source = read('UserExternalEmailDialog/UserExternalEmailDialog.js')
|
||||
const helper = source.match(/export function externalEmailEndpoints\(selfManaged\) \{[\s\S]*?\n\}/)?.[0]
|
||||
assert.ok(helper)
|
||||
return compileFunction(`${helper.replace('export ', '')}\nreturn { externalEmailEndpoints }`)()
|
||||
}
|
||||
|
||||
test('用户管理状态映射使用明确的中文文案', () => {
|
||||
@ -68,6 +75,57 @@ test('用户安全信息使用独立弹窗并调用固定管理接口', () => {
|
||||
assert.doesNotMatch(externalSource, /\/api\/v1\/security\/external-email\/bind/)
|
||||
})
|
||||
|
||||
test('当前用户判断优先使用用户 ID 并保留账号兼容回退', () => {
|
||||
const { isSameUser } = loadStatusHelpers()
|
||||
assert.equal(isSameUser({ ID: 7, Account: 'same' }, { id: 7, account: 'other' }), true)
|
||||
assert.equal(isSameUser({ ID: 7, Account: 'same' }, { id: 8, account: 'same' }), false)
|
||||
assert.equal(isSameUser({ Account: 'same' }, { account: 'same' }), true)
|
||||
assert.equal(isSameUser({ Account: 'same' }, { account: 'other' }), false)
|
||||
})
|
||||
|
||||
test('超级管理员安全码只能在部署端管理,普通用户仍使用独立弹窗', () => {
|
||||
const { canManageRecoveryCode } = loadStatusHelpers()
|
||||
assert.equal(canManageRecoveryCode({ ID: 1, Role: 'super_admin' }), false)
|
||||
assert.equal(canManageRecoveryCode({ ID: 2, Role: 'admin' }), true)
|
||||
assert.equal(canManageRecoveryCode({ ID: 3, Role: 'user' }), true)
|
||||
assert.equal(canManageRecoveryCode(null), false)
|
||||
|
||||
const source = read('UserManagement.vue')
|
||||
assert.match(source, /部署端管理/)
|
||||
assert.match(source, /仅支持本机管理/)
|
||||
assert.match(source, /:disabled="!recoveryCodeWebManaged"/)
|
||||
assert.match(source, /:self-managed="editingCurrentUser"/)
|
||||
})
|
||||
|
||||
test('编辑外部邮箱按本人和代管场景使用不同的验证码接口', () => {
|
||||
const { externalEmailEndpoints } = loadExternalEmailHelpers()
|
||||
assert.deepEqual(externalEmailEndpoints(true), {
|
||||
list: '/api/v1/security/external-email/list',
|
||||
change: '/api/v1/security/external-email/change',
|
||||
verify: '/api/v1/security/external-email/change/verify',
|
||||
})
|
||||
assert.deepEqual(externalEmailEndpoints(false), {
|
||||
list: '/api/v1/user/security/external-email/list',
|
||||
change: '/api/v1/user/security/external-email/change',
|
||||
verify: '/api/v1/user/security/external-email/verify',
|
||||
})
|
||||
|
||||
const dialogSource = read('UserExternalEmailDialog/UserExternalEmailDialog.vue')
|
||||
assert.match(dialogSource, /编辑我的外部邮箱/)
|
||||
assert.match(dialogSource, /props\.selfManaged/)
|
||||
assert.match(dialogSource, /append-to-body/)
|
||||
assert.match(dialogSource, /destroy-on-close/)
|
||||
})
|
||||
|
||||
test('普通用户编辑请求不直接携带安全码或外部邮箱', () => {
|
||||
const source = read('UserManagement.js')
|
||||
const submitStart = source.indexOf('const submit = function')
|
||||
const editRequestEnd = source.indexOf(".then(res =>", source.indexOf("http.post('/api/v1/user/edit', newData)", submitStart))
|
||||
const submitSource = source.slice(submitStart, editRequestEnd)
|
||||
assert.match(submitSource, /\/api\/v1\/user\/edit/)
|
||||
assert.doesNotMatch(submitSource, /recovery_code|recoveryCode|external_email|externalEmail/)
|
||||
})
|
||||
|
||||
test('用户管理样式可编译并保持紧凑表格布局', () => {
|
||||
const { code, errors } = compileStyle({
|
||||
source: read('UserManagement.css'),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user