验收基线:保存管理员恢复事务与协议凭据撤销回归
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-18 02:55:28 +08:00
parent a60898d682
commit 4c9490c6c5

View File

@ -0,0 +1,224 @@
//go:build linux
package adminreset
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/services/credentials"
"git.pcux.cn/xuans/Smail/utils/password"
"xorm.io/xorm"
)
const integrationOldPassword = "OldPass123"
const integrationNewPassword = "NewPass456"
func resetIntegrationDatabase(t *testing.T) (*xorm.Engine, []models.User) {
t.Helper()
engine, err := xorm.NewEngine("sqlite", filepath.Join(t.TempDir(), "reset.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = engine.Close() })
if err := engine.Sync2(&models.User{}, &models.SecurityQuestion{}, &models.RecoveryCode{},
&models.WebAuthnCredential{}, &models.ProtocolCredential{}); err != nil {
t.Fatal(err)
}
hash, err := password.HashUserPassword(integrationOldPassword)
if err != nil {
t.Fatal(err)
}
users := []models.User{
{Account: "root", Name: "恢复管理员", Role: "super_admin", IsAdmin: 1,
Password: hash, SessionVersion: 3, SecurityVersion: 5, MustChangePassword: 1},
{Account: "alice", Name: "普通用户", Role: "user",
Password: hash, SessionVersion: 7, SecurityVersion: 9},
}
for index := range users {
user := &users[index]
if _, err := engine.Insert(user); err != nil {
t.Fatal(err)
}
if _, err := engine.Insert(
&models.SecurityQuestion{UserID: user.ID, Question: "测试问题", AnswerHash: hash, Version: 1},
&models.RecoveryCode{UserID: user.ID, CodeHash: hash, CodeVersion: 1},
&models.WebAuthnCredential{UserID: user.ID, CredentialID: user.Account + "-key",
CredentialData: "test-data", DisplayName: "测试密钥", CreatedAt: time.Now()},
); err != nil {
t.Fatal(err)
}
for _, protocol := range credentials.SupportedProtocols() {
if _, err := engine.Insert(&models.ProtocolCredential{UserID: user.ID, Protocol: protocol,
Enabled: true, CodeHash: hash, CodeVersion: 1}); err != nil {
t.Fatal(err)
}
}
}
return engine, users
}
func assertResetCredentialState(t *testing.T, engine *xorm.Engine, userID int, revoked bool) {
t.Helper()
want := int64(1)
if revoked {
want = 0
}
for _, query := range []struct {
bean any
where string
count int64
}{
{&models.SecurityQuestion{}, "user_id = ?", want},
{&models.RecoveryCode{}, "user_id = ? AND used_at IS NULL", want},
{&models.WebAuthnCredential{}, "user_id = ? AND revoked_at IS NULL", want},
{&models.ProtocolCredential{}, "user_id = ? AND revoked_at IS NULL", 3 * want},
} {
count, err := engine.Where(query.where, userID).Count(query.bean)
if err != nil || count != query.count {
t.Fatalf("用户 %d 的 %T 有效数量 = %d,预期 %d,错误:%v", userID, query.bean, count, query.count, err)
}
}
}
func assertResetUserUnchanged(t *testing.T, engine *xorm.Engine, original models.User) {
t.Helper()
var actual models.User
found, err := engine.ID(original.ID).Get(&actual)
if err != nil || !found {
t.Fatalf("读取原用户失败:%v", err)
}
if actual.Password != original.Password || actual.SessionVersion != original.SessionVersion ||
actual.SecurityVersion != original.SecurityVersion || actual.MustChangePassword != original.MustChangePassword ||
actual.PasswordChangedAt != nil {
t.Fatal("失败重置或其他用户的密码与安全状态被修改")
}
assertResetCredentialState(t, engine, original.ID, false)
}
func TestUnixResetCommitsPasswordAndRevokesAllProtocolCodes(t *testing.T) {
engine, users := resetIntegrationDatabase(t)
service, err := credentials.NewService(engine)
if err != nil {
t.Fatal(err)
}
for _, protocol := range credentials.SupportedProtocols() {
if matched, err := service.CheckProtocolCode(users[0].ID, protocol, integrationOldPassword); err != nil || !matched {
t.Fatalf("重置前协议 %s 的凭据不可用:%v", protocol, err)
}
}
path := filepath.Join(t.TempDir(), "control.sock")
// 隔离夹具仅信任测试进程的 UID;不改变生产入口只信任 root 的配置。
server, err := ListenUnix(path, []uint32{uint32(os.Geteuid())})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
done := make(chan error, 1)
invalidated := make(chan bool, 1)
go func() {
done <- server.Serve(ctx, func(value string) error {
return ResetSuperAdmin(engine, value, func(id int, account string) {
var committed models.User
found, err := engine.ID(id).Get(&committed)
invalidated <- err == nil && found && id == users[0].ID && account == users[0].Account &&
committed.SessionVersion == users[0].SessionVersion+1
})
}, NewSessionGuard(time.Minute))
}()
t.Cleanup(func() {
cancel()
_ = server.Close()
select {
case err := <-done:
if err != nil {
t.Errorf("隔离控制服务退出失败:%v", err)
}
case <-time.After(2 * time.Second):
t.Error("隔离控制服务没有退出")
}
})
conn, err := DialControl(ctx, path)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
if err := SendReset(ctx, conn, integrationNewPassword); err != nil {
t.Fatal(err)
}
select {
case committed := <-invalidated:
if !committed {
t.Fatal("运行态撤销未在目标用户事务提交后执行")
}
default:
t.Fatal("未执行运行态撤销")
}
var updated models.User
if found, err := engine.ID(users[0].ID).Get(&updated); err != nil || !found {
t.Fatalf("读取更新后的管理员失败:%v", err)
}
if !password.Verify(updated.Password, integrationNewPassword) || password.Verify(updated.Password, integrationOldPassword) {
t.Fatal("新旧密码验证结果错误")
}
if updated.SessionVersion != 4 || updated.SecurityVersion != 6 || updated.MustChangePassword != 0 || updated.PasswordChangedAt == nil {
t.Fatal("密码重置的会话版本、安全版本或密码状态未提交")
}
assertResetCredentialState(t, engine, users[0].ID, true)
assertResetUserUnchanged(t, engine, users[1])
for _, protocol := range credentials.SupportedProtocols() {
for index, user := range users {
matched, err := service.CheckProtocolCode(user.ID, protocol, integrationOldPassword)
if err != nil || matched != (index == 1) {
t.Fatalf("重置后用户 %d 协议 %s 的凭据验证结果错误:%v", user.ID, protocol, err)
}
}
}
}
func TestResetSuperAdminRollsBackEveryRevocationStage(t *testing.T) {
for _, stage := range []struct{ table, operation string }{
{"recovery_code", "UPDATE"}, {"security_question", "DELETE"},
{"webauthn_credential", "UPDATE"}, {"protocol_credential", "UPDATE"},
} {
t.Run(stage.table, func(t *testing.T) {
engine, users := resetIntegrationDatabase(t)
// 在真实事务的不同阶段拒绝写入,包含前面多步已成功的最终撤销阶段。
query := "CREATE TRIGGER reject_revocation BEFORE " + stage.operation + " ON " + stage.table +
" BEGIN SELECT RAISE(ABORT, 'injected revocation failure'); END"
if _, err := engine.Exec(query); err != nil {
t.Fatal(err)
}
invalidated := false
err := ResetSuperAdmin(engine, integrationNewPassword, func(int, string) { invalidated = true })
if err == nil || !strings.Contains(err.Error(), "injected revocation failure") || invalidated {
t.Fatalf("没有命中指定撤销故障,或提前撤销运行态:%v", err)
}
for _, user := range users {
assertResetUserUnchanged(t, engine, user)
}
})
}
}
func TestResetSuperAdminRejectsAmbiguousTargetWithoutChanges(t *testing.T) {
engine, users := resetIntegrationDatabase(t)
users[1].Role, users[1].IsAdmin = "super_admin", 1
if _, err := engine.ID(users[1].ID).Cols("role", "is_admin").Update(&users[1]); err != nil {
t.Fatal(err)
}
invalidated := false
err := ResetSuperAdmin(engine, integrationNewPassword, func(int, string) { invalidated = true })
if !errors.Is(err, ErrSuperAdminAmbiguous) || invalidated {
t.Fatal("多个超级管理员时未安全拒绝")
}
for _, user := range users {
assertResetUserUnchanged(t, engine, user)
}
}