补充本机重置安全回归并修复取消后的密码提交
This commit is contained in:
parent
5c327e2b48
commit
651d7ba917
@ -164,7 +164,7 @@ func (r *localResetSession) Reset(ctx context.Context, password string) error {
|
||||
r.audit("failed", "本机密码重置同步账户失败")
|
||||
return localreset.ErrUnavailable
|
||||
}
|
||||
if err := persisted.Save(s.configPath); err != nil {
|
||||
if err := r.save(ctx, persisted); err != nil {
|
||||
rollbackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
||||
rollbackErr := s.syncPrimarySuperAdmin(rollbackCtx, previous)
|
||||
cancel()
|
||||
@ -203,3 +203,10 @@ func (r *localResetSession) audit(status, message string) {
|
||||
r.server.logger.Warn("本机密码重置审计写入失败")
|
||||
}
|
||||
}
|
||||
|
||||
func (r *localResetSession) save(ctx context.Context, cfg config.Config) error {
|
||||
if !r.current(ctx) {
|
||||
return localreset.ErrProtocol
|
||||
}
|
||||
return cfg.Save(r.server.configPath)
|
||||
}
|
||||
|
||||
397
backend/internal/httpapi/local_reset_test.go
Normal file
397
backend/internal/httpapi/local_reset_test.go
Normal file
@ -0,0 +1,397 @@
|
||||
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("失败验证没有清理摘要")
|
||||
}
|
||||
}
|
||||
146
backend/internal/notification/security_test.go
Normal file
146
backend/internal/notification/security_test.go
Normal file
@ -0,0 +1,146 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSecurityCodeHTTPRecipientIsolation(t *testing.T) {
|
||||
received := make(chan mailPayload, 1)
|
||||
endpoint := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var payload mailPayload
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Error(err)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
received <- payload
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer endpoint.Close()
|
||||
cfg := newTestConfig(endpoint.URL)
|
||||
cfg.Recipients = []string{"unrelated@example.test"}
|
||||
service := New(cfg, nil)
|
||||
if err := service.SendSecurityCode(t.Context(), "bound@example.test", "01234567"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload := <-received
|
||||
if len(payload.To) != 1 || payload.To[0].Email != "bound@example.test" || len(payload.CC) != 0 || len(payload.BCC) != 0 {
|
||||
t.Fatal("验证码只能发送到绑定邮箱")
|
||||
}
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(encoded), "unrelated@example.test") || !strings.Contains(payload.Text, "01234567") || !strings.Contains(payload.HTML, "01234567") {
|
||||
t.Fatal("验证码内容或收件人隔离错误")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityCodeRejectsInvalidInputWithoutSending(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
endpoint := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer endpoint.Close()
|
||||
for _, tc := range []struct{ name, recipient, code string }{
|
||||
{"short", "bound@example.test", "1234567"},
|
||||
{"long", "bound@example.test", "123456789"},
|
||||
{"letters", "bound@example.test", "1234567a"},
|
||||
{"space", "bound@example.test", "1234567 "},
|
||||
{"empty", "", "12345678"},
|
||||
{"multiple", "a@example.test,b@example.test", "12345678"},
|
||||
{"display-name", "Admin <a@example.test>", "12345678"},
|
||||
{"header-injection", "a@example.test\r\nBcc: b@example.test", "12345678"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if err := New(newTestConfig(endpoint.URL), nil).SendSecurityCode(t.Context(), tc.recipient, tc.code); err == nil {
|
||||
t.Fatal("无效验证码或收件人被接受")
|
||||
}
|
||||
})
|
||||
}
|
||||
if calls.Load() != 0 {
|
||||
t.Fatal("校验失败仍然发送了邮件")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityCodeRequiresConfiguredChannel(t *testing.T) {
|
||||
cfg := newTestConfig("")
|
||||
cfg.HTTPEnabled = false
|
||||
for _, service := range []*Service{nil, New(cfg, nil)} {
|
||||
if err := service.SendSecurityCode(t.Context(), "bound@example.test", "12345678"); err == nil {
|
||||
t.Fatal("未配置邮件通道不能报告成功")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityCodeCancellationAndFailureDoNotLeak(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
endpoint := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
_, _ = w.Write([]byte("12345678 sensitive-upstream-detail"))
|
||||
}))
|
||||
defer endpoint.Close()
|
||||
service := New(newTestConfig(endpoint.URL), nil)
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
if err := service.SendSecurityCode(ctx, "bound@example.test", "12345678"); !errors.Is(err, context.Canceled) {
|
||||
t.Fatal("已取消的发送必须立即失败")
|
||||
}
|
||||
if calls.Load() != 0 {
|
||||
t.Fatal("取消后仍发起网络请求")
|
||||
}
|
||||
err := service.SendSecurityCode(t.Context(), "bound@example.test", "12345678")
|
||||
if err == nil || strings.Contains(err.Error(), "12345678") || strings.Contains(err.Error(), "sensitive-upstream-detail") {
|
||||
t.Fatal("发送失败泄露了验证码或上游响应")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityCodeSMTPAndHTTPFallback(t *testing.T) {
|
||||
for _, fallback := range []bool{false, true} {
|
||||
name := "smtp-only"
|
||||
if fallback {
|
||||
name = "http-fallback"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
smtp := newSMTPTestServer(t, true, false)
|
||||
host, port := smtp.address()
|
||||
cfg := smtpTestConfig(host, port, "implicit_tls")
|
||||
var calls atomic.Int32
|
||||
endpoint := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
}))
|
||||
defer endpoint.Close()
|
||||
cfg.HTTPEnabled = fallback
|
||||
cfg.Endpoint = endpoint.URL
|
||||
cfg.HTTPPath = "/api/email/send"
|
||||
cfg.HTTPAuthMode = "none"
|
||||
service := New(cfg, credentialReaderStub{values: map[string]string{"username": "user", "password": "test-password"}})
|
||||
service.smtpTLSConfig = smtp.clientTLSConfig
|
||||
if err := service.SendSecurityCode(t.Context(), "bound@example.test", "12345678"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
smtp.mu.Lock()
|
||||
defer smtp.mu.Unlock()
|
||||
if len(smtp.messages) != 1 {
|
||||
t.Fatal("验证码邮件发送数量错误")
|
||||
}
|
||||
message := string(smtp.messages[0])
|
||||
if !strings.Contains(message, "bound@example.test") || strings.Contains(message, "receiver@example.com") {
|
||||
t.Fatal("备用通道未保持绑定邮箱隔离")
|
||||
}
|
||||
if fallback && calls.Load() != 1 {
|
||||
t.Fatal("未先尝试配置的 HTTP 通道")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user