398 lines
12 KiB
Go
398 lines
12 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.pcux.cn/xuans/Scert/backend/internal/config"
|
|
"git.pcux.cn/xuans/Scert/backend/internal/database"
|
|
"git.pcux.cn/xuans/Scert/backend/internal/localreset"
|
|
"git.pcux.cn/xuans/Scert/backend/internal/repository"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
func newLocalResetTestServer(t *testing.T) *Server {
|
|
t.Helper()
|
|
cfg := readyTestConfig(t.TempDir())
|
|
cfg.Admin.Username = "admin"
|
|
cfg.Admin.Email = "bound@example.test"
|
|
cfg.Admin.PasswordHash = httpUserPasswordHash(t, "OldPassword123")
|
|
cfg.Web.Hostname = "scert.example.test"
|
|
cfg.DNS.VerificationState = "verified"
|
|
path := filepath.Join(cfg.Paths.Config, "scert.json")
|
|
if err := cfg.Save(path); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
db, err := database.Open(t.Context(), cfg.Database)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
if err := database.Migrate(t.Context(), db.DB, "sqlite"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
users := repository.NewSQLUserRepository(db.DB, "sqlite")
|
|
if err := users.EnsureSuperAdmin(t.Context(), repository.User{
|
|
ID: "primary-super-admin", Username: cfg.Admin.Username, Email: cfg.Admin.Email, PasswordHash: cfg.Admin.PasswordHash,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return New(Dependencies{Config: cfg, ConfigPath: path, Repository: &stubDomainRepository{}, Users: users})
|
|
}
|
|
|
|
func preparedLocalReset(t *testing.T, server *Server) *localResetSession {
|
|
t.Helper()
|
|
var code string
|
|
factory := server.localResetFactory(func(_ context.Context, recipient, value string) error {
|
|
if recipient != "bound@example.test" {
|
|
t.Fatal("验证码收件人不是绑定邮箱")
|
|
}
|
|
code = value
|
|
return nil
|
|
})
|
|
session, err := factory(t.Context(), localreset.Peer{Identity: "test-local-admin"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(session.Close)
|
|
if err := session.Select(t.Context(), "email"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(code) != 8 {
|
|
t.Fatal("验证码长度不正确")
|
|
}
|
|
if err := session.Verify(t.Context(), code); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return session.(*localResetSession)
|
|
}
|
|
|
|
func assertLocalResetUnchanged(t *testing.T, server *Server, hash string) {
|
|
t.Helper()
|
|
if server.config.Admin.PasswordHash != hash {
|
|
t.Fatal("失败流程修改了内存密码")
|
|
}
|
|
user, err := server.users.GetUserByID(t.Context(), "primary-super-admin")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if user.PasswordHash != hash {
|
|
t.Fatal("失败流程修改了数据库密码")
|
|
}
|
|
}
|
|
|
|
func TestLocalResetPersistsAndRevokesOnlyAdminSessions(t *testing.T) {
|
|
server := newLocalResetTestServer(t)
|
|
oldHash := server.config.Admin.PasswordHash
|
|
server.authSessions["old-admin"] = authSession{UserID: "primary-super-admin"}
|
|
server.authSessions["other-user"] = authSession{UserID: "ordinary-user"}
|
|
session := preparedLocalReset(t, server)
|
|
if err := session.Reset(t.Context(), "NewPass123!"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
persisted, err := config.Load(server.configPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
user, err := server.users.GetUserByID(t.Context(), "primary-super-admin")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
hash := server.config.Admin.PasswordHash
|
|
if hash == oldHash || hash != persisted.Admin.PasswordHash || hash != user.PasswordHash {
|
|
t.Fatal("密码没有一致写入配置、数据库和内存")
|
|
}
|
|
if bcrypt.CompareHashAndPassword([]byte(hash), []byte("NewPass123!")) != nil {
|
|
t.Fatal("新密码校验失败")
|
|
}
|
|
if _, ok := server.authSessions["old-admin"]; ok {
|
|
t.Fatal("旧管理员会话仍然有效")
|
|
}
|
|
if _, ok := server.authSessions["other-user"]; !ok {
|
|
t.Fatal("误注销其他用户")
|
|
}
|
|
if err := session.Reset(t.Context(), "NextPass123!"); err == nil {
|
|
t.Fatal("允许重放密码重置")
|
|
}
|
|
if session.codeHash != [32]byte{} {
|
|
t.Fatal("验证码摘要未清除")
|
|
}
|
|
}
|
|
|
|
func TestLocalResetRejectsInvalidState(t *testing.T) {
|
|
for _, scenario := range []string{"wrong-code", "expired", "changed-admin", "cancelled", "closed", "skip-select"} {
|
|
t.Run(scenario, func(t *testing.T) {
|
|
server := newLocalResetTestServer(t)
|
|
oldHash := server.config.Admin.PasswordHash
|
|
now := time.Now()
|
|
server.now = func() time.Time { return now }
|
|
var code string
|
|
factory := server.localResetFactory(func(_ context.Context, _, value string) error { code = value; return nil })
|
|
session, err := factory(t.Context(), localreset.Peer{Identity: "test-local-admin"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer session.Close()
|
|
if scenario != "skip-select" {
|
|
if err := session.Select(t.Context(), "email"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
defer cancel()
|
|
switch scenario {
|
|
case "wrong-code":
|
|
code = "invalid-code"
|
|
case "expired":
|
|
now = now.Add(localreset.SessionTimeout)
|
|
case "changed-admin":
|
|
server.config.Admin.Email = "changed@example.test"
|
|
case "cancelled":
|
|
cancel()
|
|
case "closed":
|
|
session.Close()
|
|
}
|
|
if err := session.Verify(ctx, code); err == nil {
|
|
t.Fatal("无效会话通过验证")
|
|
}
|
|
if err := session.Reset(t.Context(), "NewPass123!"); err == nil {
|
|
t.Fatal("无效会话允许重置密码")
|
|
}
|
|
assertLocalResetUnchanged(t, server, oldHash)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLocalResetRejectsUnavailableSetupAndChannel(t *testing.T) {
|
|
for _, scenario := range []string{"bootstrap", "missing-email", "missing-hash", "missing-path", "nil-sender", "cancelled"} {
|
|
t.Run(scenario, func(t *testing.T) {
|
|
server := newLocalResetTestServer(t)
|
|
sender := resetCodeSender(func(context.Context, string, string) error { return nil })
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
defer cancel()
|
|
switch scenario {
|
|
case "bootstrap":
|
|
server.config.SetupReady = false
|
|
case "missing-email":
|
|
server.config.Admin.Email = ""
|
|
case "missing-hash":
|
|
server.config.Admin.PasswordHash = ""
|
|
case "missing-path":
|
|
server.configPath = ""
|
|
case "nil-sender":
|
|
sender = nil
|
|
case "cancelled":
|
|
cancel()
|
|
}
|
|
if session, err := server.localResetFactory(sender)(ctx, localreset.Peer{}); err == nil || session != nil {
|
|
t.Fatal("未就绪的服务创建了重置会话")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLocalResetMailCooldownIsSharedAndConcurrent(t *testing.T) {
|
|
server := newLocalResetTestServer(t)
|
|
now := time.Now()
|
|
server.now = func() time.Time { return now }
|
|
var sends atomic.Int32
|
|
factory := server.localResetFactory(func(context.Context, string, string) error { sends.Add(1); return nil })
|
|
var wait sync.WaitGroup
|
|
for range 8 {
|
|
wait.Add(1)
|
|
go func() {
|
|
defer wait.Done()
|
|
session, err := factory(t.Context(), localreset.Peer{Identity: "test-local-admin"})
|
|
if err != nil {
|
|
t.Error(err)
|
|
return
|
|
}
|
|
defer session.Close()
|
|
_ = session.Select(t.Context(), "email")
|
|
}()
|
|
}
|
|
wait.Wait()
|
|
if sends.Load() != 1 {
|
|
t.Fatal("并发会话绕过了发送冷却")
|
|
}
|
|
now = now.Add(time.Minute)
|
|
session, err := factory(t.Context(), localreset.Peer{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer session.Close()
|
|
if err := session.Select(t.Context(), "email"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if sends.Load() != 2 {
|
|
t.Fatal("冷却到期后仍无法发送")
|
|
}
|
|
}
|
|
|
|
type localResetFaultUsers struct {
|
|
repository.UserRepository
|
|
ensure func(context.Context, repository.User) error
|
|
}
|
|
|
|
func (r *localResetFaultUsers) EnsureSuperAdmin(ctx context.Context, user repository.User) error {
|
|
return r.ensure(ctx, user)
|
|
}
|
|
|
|
func TestLocalResetPersistenceFailureBoundaries(t *testing.T) {
|
|
for _, scenario := range []string{"database", "config-save", "cancel-after-database", "rollback"} {
|
|
t.Run(scenario, func(t *testing.T) {
|
|
server := newLocalResetTestServer(t)
|
|
oldHash := server.config.Admin.PasswordHash
|
|
session := preparedLocalReset(t, server)
|
|
base := server.users
|
|
calls := 0
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
defer cancel()
|
|
server.users = &localResetFaultUsers{UserRepository: base, ensure: func(callCtx context.Context, user repository.User) error {
|
|
calls++
|
|
if calls == 1 && scenario == "database" {
|
|
return errors.New("injected-database-failure")
|
|
}
|
|
if calls == 2 {
|
|
if callCtx.Err() != nil {
|
|
t.Fatal("补偿回滚继承了取消状态")
|
|
}
|
|
deadline, ok := callCtx.Deadline()
|
|
if !ok || time.Until(deadline) > 6*time.Second {
|
|
t.Fatal("补偿回滚没有超时上限")
|
|
}
|
|
if scenario == "rollback" {
|
|
return errors.New("injected-rollback-failure")
|
|
}
|
|
}
|
|
if err := base.EnsureSuperAdmin(callCtx, user); err != nil {
|
|
return err
|
|
}
|
|
if calls == 1 {
|
|
if scenario == "cancel-after-database" {
|
|
cancel()
|
|
}
|
|
if scenario == "config-save" || scenario == "rollback" {
|
|
if err := os.Rename(server.configPath, server.configPath+".preserved"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Mkdir(server.configPath, 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}}
|
|
if err := session.Reset(ctx, "NewPass123!"); !errors.Is(err, localreset.ErrUnavailable) {
|
|
t.Fatalf("失败流程报告了成功或错误类型不正确:%v", err)
|
|
}
|
|
if scenario == "database" {
|
|
if calls != 1 {
|
|
t.Fatal("数据库失败后不应继续提交")
|
|
}
|
|
} else if calls != 2 {
|
|
t.Fatal("持久化失败没有尝试回滚数据库")
|
|
}
|
|
if server.config.Admin.PasswordHash != oldHash {
|
|
t.Fatal("失败时更新了内存密码")
|
|
}
|
|
if scenario != "rollback" {
|
|
assertLocalResetUnchanged(t, server, oldHash)
|
|
}
|
|
if scenario == "database" || scenario == "cancel-after-database" {
|
|
persisted, err := config.Load(server.configPath)
|
|
if err != nil || persisted.Admin.PasswordHash != oldHash {
|
|
t.Fatal("失败时更改了配置密码")
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLocalResetConcurrentVerifiedSessionsOnlyOneCommits(t *testing.T) {
|
|
server := newLocalResetTestServer(t)
|
|
first := preparedLocalReset(t, server)
|
|
second := preparedLocalReset(t, server)
|
|
var successes atomic.Int32
|
|
var wait sync.WaitGroup
|
|
for _, session := range []*localResetSession{first, second} {
|
|
wait.Add(1)
|
|
go func(session *localResetSession) {
|
|
defer wait.Done()
|
|
if err := session.Reset(t.Context(), "NewPass123!"); err == nil {
|
|
successes.Add(1)
|
|
}
|
|
}(session)
|
|
}
|
|
wait.Wait()
|
|
if successes.Load() != 1 {
|
|
t.Fatal("并发重置没有保证单次提交")
|
|
}
|
|
}
|
|
|
|
func TestLocalResetFailureConsumesVerification(t *testing.T) {
|
|
for _, scenario := range []string{"weak-password", "config-conflict", "locked"} {
|
|
t.Run(scenario, func(t *testing.T) {
|
|
server := newLocalResetTestServer(t)
|
|
oldHash := server.config.Admin.PasswordHash
|
|
session := preparedLocalReset(t, server)
|
|
password := "NewPass123!"
|
|
switch scenario {
|
|
case "weak-password":
|
|
password = "short"
|
|
case "config-conflict":
|
|
cfg, err := config.Load(server.configPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg.Admin.Email = "changed@example.test"
|
|
if err := cfg.Save(server.configPath); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
case "locked":
|
|
server.tlsMu.Lock()
|
|
defer server.tlsMu.Unlock()
|
|
}
|
|
if err := session.Reset(t.Context(), password); err == nil {
|
|
t.Fatal("失败条件被忽略")
|
|
}
|
|
if err := session.Reset(t.Context(), "NextPass123!"); err == nil {
|
|
t.Fatal("失败后允许重用验证码")
|
|
}
|
|
assertLocalResetUnchanged(t, server, oldHash)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLocalResetSendFailureConsumesSession(t *testing.T) {
|
|
server := newLocalResetTestServer(t)
|
|
var code string
|
|
session, err := server.localResetFactory(func(_ context.Context, _, value string) error {
|
|
code = value
|
|
return errors.New("injected-send-failure")
|
|
})(t.Context(), localreset.Peer{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer session.Close()
|
|
if err := session.Select(t.Context(), "email"); err == nil {
|
|
t.Fatal("发送失败被忽略")
|
|
}
|
|
if err := session.Verify(t.Context(), code); err == nil {
|
|
t.Fatal("发送失败仍可验证")
|
|
}
|
|
if err := session.Select(t.Context(), "email"); err == nil {
|
|
t.Fatal("失败会话被重新使用")
|
|
}
|
|
if session.(*localResetSession).codeHash == sha256.Sum256([]byte(code)) {
|
|
t.Fatal("失败验证没有清理摘要")
|
|
}
|
|
}
|