187 lines
7.0 KiB
Go
187 lines
7.0 KiB
Go
package recovery
|
|
|
|
import (
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.pcux.cn/xuans/Smail/internal/passwordtest"
|
|
"git.pcux.cn/xuans/Smail/models"
|
|
"git.pcux.cn/xuans/Smail/services/access"
|
|
"git.pcux.cn/xuans/Smail/services/verification"
|
|
"git.pcux.cn/xuans/Smail/utils/password"
|
|
)
|
|
|
|
func TestRecoveryBusyPreservesTicket(t *testing.T) {
|
|
service, _, _, user := newRecoveryFixture(t)
|
|
ticketID, err := service.issueTicket(user.ID, user.SessionVersion)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
release := passwordtest.Hold(t)
|
|
for index := 0; index < 12; index++ {
|
|
if err := service.Complete(ticketID, "NewPass123"); !errors.Is(err, password.ErrBusy) {
|
|
t.Fatalf("繁忙期间票据失效或返回错误:%v", err)
|
|
}
|
|
}
|
|
release()
|
|
if err := service.Complete(ticketID, "NewPass123"); err != nil {
|
|
t.Fatalf("繁忙恢复后不能使用原票据:%v", err)
|
|
}
|
|
if err := service.Complete(ticketID, "OtherPass12"); !errors.Is(err, ErrRecoveryExpired) {
|
|
t.Fatalf("成功后票据未保持一次性:%v", err)
|
|
}
|
|
}
|
|
|
|
func TestRecoveryBusyDoesNotRestoreRevokedTicket(t *testing.T) {
|
|
service, _, _, user := newRecoveryFixture(t)
|
|
ticketID, err := service.issueTicket(user.ID, user.SessionVersion)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
release := passwordtest.Hold(t)
|
|
if err := service.Complete(ticketID, "NewPass123"); !errors.Is(err, password.ErrBusy) {
|
|
t.Fatal(err)
|
|
}
|
|
service.InvalidateUser(user.ID)
|
|
release()
|
|
if err := service.Complete(ticketID, "NewPass123"); !errors.Is(err, ErrRecoveryExpired) {
|
|
t.Fatalf("撤销的票据被恢复:%v", err)
|
|
}
|
|
}
|
|
|
|
func TestEmailContinuationBusyPreservesAttemptsAndCode(t *testing.T) {
|
|
service, verify, engine, user := newRecoveryFixture(t)
|
|
user.ApplyRole(access.RoleAdmin)
|
|
if _, err := engine.ID(user.ID).Cols("role", "is_admin").Update(user); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
codes, err := service.credentials.GenerateRecoveryCodes(user.ID, 5)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var code string
|
|
verify.Send = func(_ verification.Challenge, issued string) error { code = issued; return nil }
|
|
challenge, err := service.Begin(BeginRequest{Identifier: user.Account, Email: "alice@example.com", BrowserContext: "capacity-browser"}, "192.0.2.80")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
result, err := service.VerifyEmailStepWithContext(challenge, user.Account, "alice@example.com", code, "192.0.2.80", "capacity-browser")
|
|
if err != nil || !result.RequiresRecoveryCode || result.Continuation == "" {
|
|
t.Fatalf("管理员邮箱验证未生成中间凭证:%v", err)
|
|
}
|
|
release := passwordtest.Hold(t)
|
|
for index := 0; index < 12; index++ {
|
|
if _, err := service.ContinueEmailWithContext(result.Continuation, user.Account, "alice@example.com", codes[0], "capacity-browser"); !errors.Is(err, password.ErrBusy) {
|
|
t.Fatalf("繁忙时中间凭证不可重试:%v", err)
|
|
}
|
|
}
|
|
pending := service.emailPending[ticketKey(result.Continuation)]
|
|
if pending == nil || pending.Busy || pending.Attempts != 0 {
|
|
t.Fatal("繁忙误计失败次数或未释放中间凭证")
|
|
}
|
|
release()
|
|
ticketID, err := service.ContinueEmailWithContext(result.Continuation, user.Account, "alice@example.com", codes[0], "capacity-browser")
|
|
if err != nil || ticketID == "" {
|
|
t.Fatalf("繁忙结束后无法继续恢复:%v", err)
|
|
}
|
|
if _, err := service.ContinueEmailWithContext(result.Continuation, user.Account, "alice@example.com", codes[0], "capacity-browser"); !errors.Is(err, ErrRecoveryExpired) {
|
|
t.Fatalf("中间凭证被重复使用:%v", err)
|
|
}
|
|
if err := service.CompleteWithContext(ticketID, "NewPass123", "capacity-browser"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestPublicLinkBusyPreservesAttemptsAndSingleUse(t *testing.T) {
|
|
for _, role := range []access.Role{access.RoleUser, access.RoleAdmin} {
|
|
t.Run(string(role), func(t *testing.T) {
|
|
service, _, engine, user := newRecoveryFixture(t)
|
|
user.ApplyRole(role)
|
|
if _, err := engine.ID(user.ID).Cols("role", "is_admin").Update(user); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var recoveryCode string
|
|
if role == access.RoleAdmin {
|
|
codes, err := service.credentials.GenerateRecoveryCodes(user.ID, 5)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
recoveryCode = codes[0]
|
|
}
|
|
var token string
|
|
service.SetPublicLinkDelivery(func(_ string, issued string) error { token = issued; return nil })
|
|
if err := service.BeginPublicLink(user.Account); err != nil || token == "" {
|
|
t.Fatalf("未生成恢复链接:%v", err)
|
|
}
|
|
release := passwordtest.Hold(t)
|
|
for index := 0; index < 12; index++ {
|
|
if err := service.CompletePublicLink(token, "NewPass123", recoveryCode); !errors.Is(err, password.ErrBusy) {
|
|
t.Fatalf("繁忙时链接不可重试:%v", err)
|
|
}
|
|
}
|
|
pending := service.publicTickets[ticketKey(token)]
|
|
if pending == nil || pending.Busy || pending.Attempts != 0 {
|
|
t.Fatal("繁忙误计失败次数或未释放链接")
|
|
}
|
|
release()
|
|
if err := service.CompletePublicLink(token, "NewPass123", recoveryCode); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := service.CompletePublicLink(token, "NextPass12", recoveryCode); !errors.Is(err, ErrRecoveryExpired) {
|
|
t.Fatalf("恢复链接被重复使用:%v", err)
|
|
}
|
|
var stored models.User
|
|
if found, err := engine.ID(user.ID).Get(&stored); err != nil || !found || stored.SessionVersion != user.SessionVersion+1 {
|
|
t.Fatalf("重置未撤销旧会话版本:%v", err)
|
|
}
|
|
if matched, err := password.Check(stored.Password, "NewPass123"); !matched || err != nil {
|
|
t.Fatalf("新密码不可用:%v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPublicLinkBusyDuringCodeCheckRollsBack(t *testing.T) {
|
|
service, _, engine, user := newRecoveryFixture(t)
|
|
user.ApplyRole(access.RoleAdmin)
|
|
if _, err := engine.ID(user.ID).Cols("role", "is_admin").Update(user); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
codes, err := service.credentials.GenerateRecoveryCodes(user.ID, 5)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var token string
|
|
service.SetPublicLinkDelivery(func(_ string, issued string) error { token = issued; return nil })
|
|
if err := service.BeginPublicLink(user.Account); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
now, calls := service.now, 0
|
|
var release func()
|
|
// 第二次读取时钟位于新密码哈希之后、恢复码事务之前。
|
|
service.now = func() time.Time {
|
|
calls++
|
|
if calls == 2 {
|
|
release = passwordtest.Hold(t)
|
|
}
|
|
return now()
|
|
}
|
|
if err := service.CompletePublicLink(token, "NewPass123", codes[0]); !errors.Is(err, password.ErrBusy) || release == nil {
|
|
t.Fatalf("未覆盖事务内恢复码繁忙分支:%v", err)
|
|
}
|
|
service.now = now
|
|
release()
|
|
pending := service.publicTickets[ticketKey(token)]
|
|
if pending == nil || pending.Busy || pending.Attempts != 0 {
|
|
t.Fatal("恢复码繁忙错误地消耗了链接尝试次数")
|
|
}
|
|
var stored models.User
|
|
if found, err := engine.ID(user.ID).Get(&stored); err != nil || !found || stored.Password != user.Password || stored.SessionVersion != user.SessionVersion {
|
|
t.Fatalf("繁忙事务未保持原密码和会话版本:%v", err)
|
|
}
|
|
if err := service.CompletePublicLink(token, "NewPass123", codes[0]); err != nil {
|
|
t.Fatalf("事务繁忙后同一链接及恢复码不可用:%v", err)
|
|
}
|
|
}
|