Smail/backend/services/adminreset/reset_integration_linux_test.go
smail 4c9490c6c5
Some checks are pending
Security Checks / security (push) Waiting to run
验收基线:保存管理员恢复事务与协议凭据撤销回归
2026-09-18 02:55:28 +08:00

225 lines
7.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//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)
}
}