修复跨源请求与长操作超时并加固DNS验证和错误脱敏
This commit is contained in:
parent
b0a846df0f
commit
00ea12cbd9
@ -366,5 +366,11 @@ func DiagnoseAliyunError(err error, domainName string) string {
|
||||
if strings.Contains(lowered, "invalidaccesskeyid.notfound") || strings.Contains(lowered, "signaturedoesnotmatch") {
|
||||
return "阿里云 AccessKey ID 或 AccessKey Secret 无效,请检查系统 DNS 凭据配置"
|
||||
}
|
||||
return fmt.Sprintf("阿里云 RAM 权限验证未通过:%s", msg)
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return "阿里云 RAM 权限验证已取消,当前权限尚未确认"
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return "阿里云 RAM 权限验证超时,请检查网络后重新验证"
|
||||
}
|
||||
return "阿里云 RAM 权限验证未通过,请检查网络、凭据和目标域名权限;不要将未确认状态视为已授权"
|
||||
}
|
||||
|
||||
@ -137,13 +137,17 @@ func (s *Server) checkAliyunDNSDomain(c *gin.Context) {
|
||||
}
|
||||
|
||||
report, verifyErr := s.aliyunChecker.Verify(c.Request.Context(), targetDomain, credentials)
|
||||
report = sanitizeAliyunReport(report, targetDomain)
|
||||
if verifyErr != nil {
|
||||
diagnosis := dnsprovider.DiagnoseAliyunError(verifyErr, targetDomain)
|
||||
report.Verified = false
|
||||
report.Diagnosis = diagnosis
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"verified": false,
|
||||
"domain": targetDomain,
|
||||
"matched_domain": report.MatchedDomain,
|
||||
"diagnosis": report.Diagnosis,
|
||||
"error": verifyErr.Error(),
|
||||
"error": diagnosis,
|
||||
"report": report,
|
||||
})
|
||||
return
|
||||
@ -180,7 +184,7 @@ func (s *Server) validateDomainAliyunAuthorization(ctx context.Context, domainNa
|
||||
|
||||
credentials, err := s.readDNSCredentials(ctx, credentialRef)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取阿里云 DNS 凭据失败,不能确认域名授权:%w", err)
|
||||
return errors.New("读取阿里云 DNS 凭据失败,不能确认域名授权,请检查受保护的凭据存储")
|
||||
}
|
||||
if _, err := dnsprovider.ValidateCredentials(dnsprovider.Aliyun, credentials); err != nil {
|
||||
return fmt.Errorf("阿里云 DNS 凭据无效,不能确认域名授权:%w", err)
|
||||
@ -193,6 +197,25 @@ func (s *Server) validateDomainAliyunAuthorization(ctx context.Context, domainNa
|
||||
return nil
|
||||
}
|
||||
|
||||
func sanitizeAliyunReport(report dnsprovider.AuthorizationReport, requestedDomain string) dnsprovider.AuthorizationReport {
|
||||
if report.Diagnosis != "" {
|
||||
report.Diagnosis = dnsprovider.DiagnoseAliyunError(errors.New(report.Diagnosis), requestedDomain)
|
||||
}
|
||||
report.Domains = append([]dnsprovider.AuthorizationDomain(nil), report.Domains...)
|
||||
for index := range report.Domains {
|
||||
item := &report.Domains[index]
|
||||
if item.Error == "" {
|
||||
continue
|
||||
}
|
||||
if item.Writable && !item.Cleanup {
|
||||
item.Error = "测试记录写入成功但清理失败,请检查删除权限并核对残留测试记录"
|
||||
} else {
|
||||
item.Error = dnsprovider.DiagnoseAliyunError(errors.New(item.Error), item.Domain)
|
||||
}
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
func (s *Server) readDNSCredentials(ctx context.Context, credentialRef string) (map[string]string, error) {
|
||||
if s == nil || strings.TrimSpace(credentialRef) == "" {
|
||||
return nil, errors.New("DNS 凭据引用未配置")
|
||||
|
||||
51
backend/internal/httpapi/operation_timeout.go
Normal file
51
backend/internal/httpapi/operation_timeout.go
Normal file
@ -0,0 +1,51 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const operationResponseGrace = 2 * time.Minute
|
||||
|
||||
// 仅为明确的长操作延长响应期限,普通请求仍受服务器默认写超时保护。
|
||||
// 工作流使用有限上下文,额外响应时间留给 DNS 清理、部署回滚和结果序列化。
|
||||
func (s *Server) longOperation(timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
deadline, _ := ctx.Deadline()
|
||||
controller := http.NewResponseController(c.Writer)
|
||||
if err := controller.SetWriteDeadline(deadline.Add(operationResponseGrace)); err != nil && !errors.Is(err, http.ErrNotSupported) {
|
||||
s.writeError(c, http.StatusServiceUnavailable, "无法设置长操作响应期限,请稍后重试", err)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) dnsOperation(c *gin.Context) {
|
||||
s.longOperation(s.dnsVerificationTimeout())(c)
|
||||
}
|
||||
|
||||
func (s *Server) requireSameOrigin(c *gin.Context) {
|
||||
if !s.sameOriginForAPI(c.Request) {
|
||||
s.writeError(c, http.StatusForbidden, "请求来源校验失败", nil)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
|
||||
func (s *Server) requireSetupAccess(c *gin.Context) {
|
||||
if !s.allowSetupRequest(c) {
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
@ -13,6 +13,14 @@ func (s *Server) runRenewalNow(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
report, err := s.runRenewal(c.Request.Context())
|
||||
if len(report.Errors) > 0 {
|
||||
// 保留失败数量,不将证书路径、凭据或下游 SDK 响应带回浏览器。
|
||||
messages := make([]string, len(report.Errors))
|
||||
for index := range messages {
|
||||
messages[index] = "证书续期失败,请检查对应任务及受保护的服务日志"
|
||||
}
|
||||
report.Errors = messages
|
||||
}
|
||||
response := gin.H{"report": report}
|
||||
if err != nil {
|
||||
if s.logger != nil {
|
||||
|
||||
61
backend/internal/httpapi/report_safety_test.go
Normal file
61
backend/internal/httpapi/report_safety_test.go
Normal file
@ -0,0 +1,61 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/dnsprovider"
|
||||
)
|
||||
|
||||
func TestRenewalReportHidesNestedWorkflowErrors(t *testing.T) {
|
||||
server := New(Dependencies{
|
||||
Config: readyTestConfig(t.TempDir()),
|
||||
RunRenewal: func(context.Context) (certificate.RenewalReport, error) {
|
||||
return certificate.RenewalReport{Failed: 1, Errors: []string{"internal-secret-sentinel"}}, nil
|
||||
},
|
||||
})
|
||||
response := httptest.NewRecorder()
|
||||
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/renewal/run", nil))
|
||||
if response.Code != http.StatusOK || strings.Contains(response.Body.String(), "internal-secret-sentinel") {
|
||||
t.Fatalf("续期报告不得返回底层敏感错误:%d", response.Code)
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), `"failed":1`) {
|
||||
t.Fatal("脱敏不能丢失续期失败计数")
|
||||
}
|
||||
}
|
||||
|
||||
type unsafeReportChecker struct {
|
||||
fakeAliyunScopeChecker
|
||||
}
|
||||
|
||||
func (f *unsafeReportChecker) Verify(context.Context, string, map[string]string) (dnsprovider.AuthorizationReport, error) {
|
||||
err := errors.New("internal-secret-sentinel")
|
||||
return dnsprovider.AuthorizationReport{
|
||||
Diagnosis: err.Error(),
|
||||
Domains: []dnsprovider.AuthorizationDomain{{Domain: "example.test", Writable: true, Cleanup: false, Error: err.Error()}},
|
||||
}, err
|
||||
}
|
||||
|
||||
func TestAliyunReportHidesSDKErrorAndPreservesCleanupFailure(t *testing.T) {
|
||||
cfg := readyTestConfig(t.TempDir())
|
||||
cfg.DNS.Provider = dnsprovider.Aliyun
|
||||
cfg.DNS.CredentialRef = "secret://test/only"
|
||||
server := New(Dependencies{
|
||||
Config: cfg, AliyunChecker: &unsafeReportChecker{},
|
||||
Secrets: &fakeSecretReaderStore{data: map[string]map[string]string{cfg.DNS.CredentialRef: {"access_key_id": "test-id", "access_key_secret": "test-secret"}}},
|
||||
})
|
||||
response := httptest.NewRecorder()
|
||||
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/dns/aliyun/check", strings.NewReader(`{"domain":"example.test"}`)))
|
||||
body := response.Body.String()
|
||||
if response.Code != http.StatusOK || strings.Contains(body, "internal-secret-sentinel") {
|
||||
t.Fatalf("阿里云验证报告不得返回 SDK 敏感错误:%d", response.Code)
|
||||
}
|
||||
if !strings.Contains(body, `"writable":true`) || !strings.Contains(body, `"cleanup":false`) || !strings.Contains(body, `"verified":false`) {
|
||||
t.Fatal("脱敏必须保留写入成功但清理失败的验收事实")
|
||||
}
|
||||
}
|
||||
204
backend/internal/httpapi/request_safety_test.go
Normal file
204
backend/internal/httpapi/request_safety_test.go
Normal file
@ -0,0 +1,204 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/config"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/setup"
|
||||
)
|
||||
|
||||
func TestAuthMutationRejectsCrossOrigin(t *testing.T) {
|
||||
cfg := readyTestConfig(t.TempDir())
|
||||
cfg.Admin.Username = "admin"
|
||||
cfg.Admin.PasswordHash = httpUserPasswordHash(t, "AdminPass123")
|
||||
server := New(Dependencies{Config: cfg})
|
||||
for _, path := range []string{"/api/v1/auth/login", "/api/v1/auth/logout"} {
|
||||
for _, header := range []string{"Origin", "Referer"} {
|
||||
t.Run(path+"/"+header, func(t *testing.T) {
|
||||
cookie := loginAuthTest(t, server, "admin", "AdminPass123", false).Result().Cookies()[0]
|
||||
request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"identifier":"admin","password":"AdminPass123"}`))
|
||||
request.Header.Set("Content-Type", "text/plain")
|
||||
request.Header.Set(header, "https://untrusted.example")
|
||||
request.AddCookie(cookie)
|
||||
response := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(response, request)
|
||||
if response.Code != http.StatusForbidden {
|
||||
t.Fatalf("跨源认证操作应被拒绝:%d", response.Code)
|
||||
}
|
||||
if len(response.Result().Cookies()) != 0 {
|
||||
t.Fatal("跨源请求不应设置或清除会话 Cookie")
|
||||
}
|
||||
if _, exists := server.authSessions[cookie.Value]; !exists {
|
||||
t.Fatal("跨源退出不应删除已有会话")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMutationAllowsSameOrigin(t *testing.T) {
|
||||
cfg := readyTestConfig(t.TempDir())
|
||||
cfg.Admin.Username = "admin"
|
||||
cfg.Admin.PasswordHash = httpUserPasswordHash(t, "AdminPass123")
|
||||
server := New(Dependencies{Config: cfg})
|
||||
for _, path := range []string{"/api/v1/auth/login", "/api/v1/auth/logout"} {
|
||||
request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"identifier":"admin","password":"AdminPass123"}`))
|
||||
request.Header.Set("Origin", "http://example.com")
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("同源认证操作应保持可用:%s %d", path, response.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupMutationRejectsCrossOriginLoopback(t *testing.T) {
|
||||
for _, path := range []string{"/api/v1/setup", "/api/v1/setup/dns/test"} {
|
||||
server := New(Dependencies{Config: config.Default(t.TempDir()), SetupDNS: &fakeSetupDNSVerifier{}})
|
||||
request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`))
|
||||
request.RemoteAddr = "127.0.0.1:12345"
|
||||
request.Header.Set("Origin", "https://untrusted.example")
|
||||
request.Header.Set("Content-Type", "text/plain")
|
||||
response := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(response, request)
|
||||
if response.Code != http.StatusForbidden {
|
||||
t.Fatalf("本机初始化也必须拒绝跨源写入:%s %d", path, response.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type delayedDNSVerifier struct {
|
||||
fakeSetupDNSVerifier
|
||||
}
|
||||
|
||||
func (f *delayedDNSVerifier) Verify(ctx context.Context, _ setup.DNSVerificationInput) (setup.DNSVerificationResult, error) {
|
||||
select {
|
||||
case <-time.After(150 * time.Millisecond):
|
||||
return setup.DNSVerificationResult{}, nil
|
||||
case <-ctx.Done():
|
||||
return setup.DNSVerificationResult{}, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSLongResponseSurvivesOrdinaryWriteTimeout(t *testing.T) {
|
||||
server := New(Dependencies{Config: config.Default(t.TempDir()), SetupDNS: &delayedDNSVerifier{}})
|
||||
// 使用真实套接字而非 ResponseRecorder,缩短默认期限以稳定复现写入超时。
|
||||
host := httptest.NewUnstartedServer(server.Handler())
|
||||
host.Config = newHTTPServer("", server.Handler())
|
||||
host.Config.WriteTimeout = 50 * time.Millisecond
|
||||
host.Start()
|
||||
defer host.Close()
|
||||
client := host.Client()
|
||||
client.Timeout = 5 * time.Second
|
||||
response, err := client.Post(host.URL+"/api/v1/setup/dns/test", "application/json", strings.NewReader(`{"provider":"aliyun","test_domain":"example.test","credentials":{}}`))
|
||||
if err != nil {
|
||||
t.Fatalf("长操作完成后浏览器必须仍可读取响应:%v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if err != nil || response.StatusCode != http.StatusOK || !strings.Contains(string(body), `"verified":true`) {
|
||||
t.Fatalf("长操作响应不完整:%d %v", response.StatusCode, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLongOperationCancelsWorkAndAllowsFinalResponse(t *testing.T) {
|
||||
server := New(Dependencies{Config: config.Default(t.TempDir())})
|
||||
server.router.POST("/test-operation", server.longOperation(50*time.Millisecond), func(c *gin.Context) {
|
||||
<-c.Request.Context().Done()
|
||||
if !errors.Is(c.Request.Context().Err(), context.DeadlineExceeded) {
|
||||
c.Status(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusGatewayTimeout, gin.H{"error": "测试工作流已停止"})
|
||||
})
|
||||
host := httptest.NewUnstartedServer(server.Handler())
|
||||
host.Config = newHTTPServer("", server.Handler())
|
||||
host.Config.WriteTimeout = 20 * time.Millisecond
|
||||
host.Start()
|
||||
defer host.Close()
|
||||
client := host.Client()
|
||||
client.Timeout = 5 * time.Second
|
||||
response, err := client.Post(host.URL+"/test-operation", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("取消后应仍能返回明确错误:%v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if err != nil || response.StatusCode != http.StatusGatewayTimeout || !strings.Contains(string(body), "测试工作流已停止") {
|
||||
t.Fatalf("超时结果未完整返回:%d %v", response.StatusCode, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupIssuanceDoesNotBlockStatusOrAllowConcurrentSetup(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
server := New(Dependencies{
|
||||
Config: config.Default(directory), ConfigPath: filepath.Join(directory, "config.json"),
|
||||
SetupDNS: &fakeSetupDNSVerifier{},
|
||||
IssueSetupCertificate: func(ctx context.Context, _ certificate.SetupCertificateRequest) (certificate.SavedResource, error) {
|
||||
close(started)
|
||||
select {
|
||||
case <-release:
|
||||
return certificate.SavedResource{}, errors.New("模拟签发失败")
|
||||
case <-ctx.Done():
|
||||
return certificate.SavedResource{}, ctx.Err()
|
||||
}
|
||||
},
|
||||
})
|
||||
payload := `{"admin_username":"admin","admin_email":"admin@example.test","admin_password":"AdminPass123","server_port":8443,"dns_provider":"aliyun","dns_credential_ref":"secret://dns/aliyun/test","dns_verification_token":"test-token","dns_test_domain":"scert.example.test","web_hostname":"scert.example.test","https_mode":"direct"}`
|
||||
request := func() *http.Request {
|
||||
r := httptest.NewRequest(http.MethodPost, "/api/v1/setup", strings.NewReader(payload))
|
||||
r.RemoteAddr = "127.0.0.1:12345"
|
||||
return r
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
server.Handler().ServeHTTP(httptest.NewRecorder(), request())
|
||||
}()
|
||||
defer func() { close(release); <-done }()
|
||||
select {
|
||||
case <-started:
|
||||
case <-done:
|
||||
t.Fatal("初始化未到达模拟签发阶段")
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("初始化启动超时")
|
||||
}
|
||||
for _, path := range []string{"/api/v1/status", "/api/v1/setup"} {
|
||||
response := httptest.NewRecorder()
|
||||
finished := make(chan struct{})
|
||||
go func() {
|
||||
defer close(finished)
|
||||
r := request()
|
||||
if path != "/api/v1/setup" {
|
||||
r = httptest.NewRequest(http.MethodGet, path, nil)
|
||||
}
|
||||
server.Handler().ServeHTTP(response, r)
|
||||
}()
|
||||
select {
|
||||
case <-finished:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("签发期间请求被全局配置锁阻塞:%s", path)
|
||||
}
|
||||
want := http.StatusOK
|
||||
if path == "/api/v1/setup" {
|
||||
want = http.StatusConflict
|
||||
}
|
||||
if response.Code != want {
|
||||
t.Fatalf("签发期间响应不正确:%s %d", path, response.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -98,6 +98,7 @@ type Server struct {
|
||||
taskMu sync.Mutex
|
||||
taskCancels map[string]context.CancelFunc
|
||||
issueTaskMu sync.Mutex
|
||||
setupMu sync.Mutex
|
||||
resetMu sync.Mutex
|
||||
resetClosedHash string
|
||||
resetLimiter *resetRateLimiter
|
||||
@ -158,8 +159,8 @@ func (s *Server) registerRoutes() {
|
||||
s.router.POST(admin.ResetPath, s.resetSubmit)
|
||||
s.router.GET("/api/v1/status", s.status)
|
||||
s.router.GET("/api/v1/auth/session", s.authSession)
|
||||
s.router.POST("/api/v1/auth/login", s.authLogin)
|
||||
s.router.POST("/api/v1/auth/logout", s.authLogout)
|
||||
s.router.POST("/api/v1/auth/login", s.requireSameOrigin, s.authLogin)
|
||||
s.router.POST("/api/v1/auth/logout", s.requireSameOrigin, s.authLogout)
|
||||
s.router.POST("/api/v1/auth/password", s.requireAuthenticated, s.changePassword)
|
||||
s.router.POST("/api/v1/password-reset/resolve", s.resolvePasswordResetAccount)
|
||||
adminAPI := s.router.Group("/api/v1", s.requireAdmin)
|
||||
@ -170,10 +171,10 @@ func (s *Server) registerRoutes() {
|
||||
userAPI.PUT("/:id", s.updateUser)
|
||||
userAPI.DELETE("/:id", s.deleteUser)
|
||||
adminAPI.GET("/domains", s.listDomains)
|
||||
adminAPI.POST("/domains", s.createDomain)
|
||||
adminAPI.PUT("/domains/:id", s.updateDomain)
|
||||
adminAPI.POST("/domains", s.dnsOperation, s.createDomain)
|
||||
adminAPI.PUT("/domains/:id", s.dnsOperation, s.updateDomain)
|
||||
adminAPI.DELETE("/domains/:id", s.deleteDomain)
|
||||
adminAPI.POST("/domains/:id/issue", s.issueDomain)
|
||||
adminAPI.POST("/domains/:id/issue", s.longOperation(certificateTaskTimeout), s.issueDomain)
|
||||
adminAPI.GET("/tasks", s.listTasks)
|
||||
adminAPI.GET("/tasks/:id", s.getTask)
|
||||
adminAPI.DELETE("/tasks/:id", s.cancelTask)
|
||||
@ -182,17 +183,17 @@ func (s *Server) registerRoutes() {
|
||||
adminAPI.GET("/settings", s.getSettings)
|
||||
adminAPI.PUT("/settings", s.updateSettings)
|
||||
adminAPI.GET("/dns/providers", s.listAdminDNSProviders)
|
||||
adminAPI.POST("/dns/test", s.testAdminDNS)
|
||||
adminAPI.POST("/dns/test", s.dnsOperation, s.testAdminDNS)
|
||||
adminAPI.GET("/dns/aliyun/scope", s.getAliyunDNSScope)
|
||||
adminAPI.POST("/dns/aliyun/check", s.checkAliyunDNSDomain)
|
||||
adminAPI.POST("/renewal/run", s.runRenewalNow)
|
||||
adminAPI.POST("/dns/aliyun/check", s.dnsOperation, s.checkAliyunDNSDomain)
|
||||
adminAPI.POST("/renewal/run", s.longOperation(certificateTaskTimeout), s.runRenewalNow)
|
||||
adminAPI.GET("/certificates", s.listCertificates)
|
||||
adminAPI.GET("/certificates/:id", s.getCertificate)
|
||||
adminAPI.POST("/domains/:id/certificate/import", s.importCertificateFiles)
|
||||
adminAPI.POST("/domains/:id/certificate/validate", s.validateCertificate)
|
||||
adminAPI.POST("/domains/:id/deploy", s.deployCertificateCurrent)
|
||||
adminAPI.POST("/domains/:id/certificate/import", s.longOperation(certificateTaskTimeout), s.importCertificateFiles)
|
||||
adminAPI.POST("/domains/:id/certificate/validate", s.longOperation(certificateTaskTimeout), s.validateCertificate)
|
||||
adminAPI.POST("/domains/:id/deploy", s.longOperation(certificateTaskTimeout), s.deployCertificateCurrent)
|
||||
adminAPI.GET("/domains/:id/certificate/versions", s.listCertificateVersions)
|
||||
adminAPI.POST("/domains/:id/certificate/versions/:version/restore", s.restoreCertificateVersion)
|
||||
adminAPI.POST("/domains/:id/certificate/versions/:version/restore", s.longOperation(certificateTaskTimeout), s.restoreCertificateVersion)
|
||||
adminAPI.GET("/domains/:id/servers", s.listDomainServers)
|
||||
adminAPI.PUT("/domains/:id/servers", s.replaceDomainServers)
|
||||
adminAPI.GET("/servers", s.listServers)
|
||||
|
||||
@ -81,8 +81,8 @@ type dnsTestRequest struct {
|
||||
func (s *Server) registerSetupRoutes() {
|
||||
s.router.GET("/api/v1/setup/status", s.setupStatus)
|
||||
s.router.GET("/api/v1/setup/dns/providers", s.setupDNSProviders)
|
||||
s.router.POST("/api/v1/setup/dns/test", s.testSetupDNS)
|
||||
s.router.POST("/api/v1/setup", s.applySetup)
|
||||
s.router.POST("/api/v1/setup/dns/test", s.requireSameOrigin, s.requireSetupAccess, s.dnsOperation, s.testSetupDNS)
|
||||
s.router.POST("/api/v1/setup", s.requireSameOrigin, s.requireSetupAccess, s.longOperation(12*time.Minute), s.applySetup)
|
||||
}
|
||||
|
||||
func (s *Server) setupStatus(c *gin.Context) {
|
||||
@ -212,13 +212,19 @@ const defaultDNSRequestTimeout = 45 * time.Second
|
||||
const dnsCleanupRequestBuffer = 30 * time.Second
|
||||
|
||||
func (s *Server) applySetup(c *gin.Context) {
|
||||
s.configMu.Lock()
|
||||
defer s.configMu.Unlock()
|
||||
if !s.setupMu.TryLock() {
|
||||
s.writeError(c, http.StatusConflict, "初始化正在执行,请等待当前操作完成", nil)
|
||||
return
|
||||
}
|
||||
defer s.setupMu.Unlock()
|
||||
s.configMu.RLock()
|
||||
cfg := s.config
|
||||
s.configMu.RUnlock()
|
||||
if !isLoopbackRequest(c.Request) {
|
||||
s.writeError(c, http.StatusForbidden, "首次初始化接口仅允许从本机访问", nil)
|
||||
return
|
||||
}
|
||||
if s.config.SetupReady {
|
||||
if cfg.SetupReady {
|
||||
s.writeError(c, http.StatusConflict, "初始化已经完成,不能重复提交", nil)
|
||||
return
|
||||
}
|
||||
@ -266,7 +272,7 @@ func (s *Server) applySetup(c *gin.Context) {
|
||||
emailCredentialRef := strings.TrimSpace(request.EmailHTTPCredentialRef)
|
||||
smtpCredentialRef := ""
|
||||
if emailSMTPEnabled {
|
||||
smtpCredentialRef = strings.TrimSpace(s.config.Notification.SMTPCredentialRef)
|
||||
smtpCredentialRef = strings.TrimSpace(cfg.Notification.SMTPCredentialRef)
|
||||
if strings.TrimSpace(request.EmailSMTPPassword) != "" {
|
||||
if strings.TrimSpace(request.EmailSMTPUsername) == "" {
|
||||
s.writeError(c, http.StatusBadRequest, "填写 SMTP 密码前必须配置 SMTP 用户名", nil)
|
||||
@ -313,7 +319,7 @@ func (s *Server) applySetup(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
updated := s.config
|
||||
updated := cfg
|
||||
err := setup.Apply(&updated, setup.Input{
|
||||
AdminUsername: request.AdminUsername,
|
||||
AdminEmail: request.AdminEmail,
|
||||
@ -483,7 +489,9 @@ func (s *Server) applySetup(c *gin.Context) {
|
||||
if err := s.setupDNS.Consume(request.DNSVerificationToken, request.DNSProvider, request.DNSCredentialRef, testDomain); err != nil && s.logger != nil {
|
||||
s.logger.Warn("初始化配置已保存,但清理 DNS 验证令牌失败", "error", err)
|
||||
}
|
||||
s.configMu.Lock()
|
||||
s.config = updated
|
||||
s.configMu.Unlock()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"initialized": true,
|
||||
"message": fmt.Sprintf(
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "node --test src/views/SetupWizard/SetupWizard.test.js src/views/Dashboard/Dashboard.test.js",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
|
||||
70
frontend/src/test-support/mount.js
Normal file
70
frontend/src/test-support/mount.js
Normal file
@ -0,0 +1,70 @@
|
||||
import { createRenderer, nextTick } from "vue";
|
||||
|
||||
export const aliyunProvider = {
|
||||
id: "aliyun",
|
||||
name: "阿里云 DNS",
|
||||
available: true,
|
||||
credential_fields: [{ name: "access_key_id", label: "访问密钥", required: true }],
|
||||
};
|
||||
|
||||
export function deferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((accept, fail) => {
|
||||
resolve = accept;
|
||||
reject = fail;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
export function verifiedResponse(token = "test-token") {
|
||||
return Response.json({
|
||||
verified: true,
|
||||
credential_ref: "secret://test/only",
|
||||
verification_token: token,
|
||||
expires_at: new Date(Date.now() + 60000).toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// 运行真实组合式逻辑和生命周期,不加载浏览器,也不连接 DNS 或业务数据库。
|
||||
export async function mountComposable(t, factory, fetcher) {
|
||||
const originals = new Map();
|
||||
for (const [key, value] of Object.entries({
|
||||
window: { setInterval: () => 1, clearInterval() {}, location: { assign() {} } },
|
||||
document: { body: { style: { overflow: "" } } },
|
||||
})) {
|
||||
originals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
||||
}
|
||||
t.mock.method(globalThis, "fetch", fetcher);
|
||||
const renderer = createRenderer({
|
||||
createComment: () => ({}),
|
||||
insert() {},
|
||||
remove() {},
|
||||
parentNode: () => null,
|
||||
nextSibling: () => null,
|
||||
});
|
||||
let state;
|
||||
const app = renderer.createApp({
|
||||
setup() {
|
||||
state = factory();
|
||||
return () => null;
|
||||
},
|
||||
});
|
||||
app.mount({});
|
||||
let mounted = true;
|
||||
const unmount = () => {
|
||||
if (mounted) app.unmount();
|
||||
mounted = false;
|
||||
};
|
||||
t.after(() => {
|
||||
unmount();
|
||||
for (const [key, descriptor] of originals) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else delete globalThis[key];
|
||||
}
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await nextTick();
|
||||
return { state, unmount };
|
||||
}
|
||||
@ -37,6 +37,9 @@ export function useDashboard() {
|
||||
const settingsError = ref("");
|
||||
const dnsTesting = ref(false);
|
||||
const dnsTestError = ref("");
|
||||
const verificationClock = ref(Date.now());
|
||||
let verificationTimer;
|
||||
let dnsRequestVersion = 0;
|
||||
const settingsForm = ref(createEmptySettingsForm());
|
||||
const showCertificateImport = ref(false);
|
||||
const importDomain = ref(null);
|
||||
@ -194,14 +197,25 @@ export function useDashboard() {
|
||||
return (
|
||||
current.provider !== saved?.provider ||
|
||||
!saved?.credential_configured ||
|
||||
Boolean(current.test_domain.trim()) ||
|
||||
Object.values(current.credentials || {}).some((value) => String(value || "").trim()) ||
|
||||
Boolean(current.verification_token) ||
|
||||
current.verification_state === "pending"
|
||||
);
|
||||
});
|
||||
const dnsReadyToSave = computed(
|
||||
() =>
|
||||
!dnsVerificationRequired.value ||
|
||||
(Boolean(settingsForm.value.dns.credential_ref) &&
|
||||
Boolean(settingsForm.value.dns.verification_token)),
|
||||
const dnsReadyToSave = computed(() => {
|
||||
verificationClock.value;
|
||||
if (dnsTesting.value) return false;
|
||||
if (!dnsVerificationRequired.value) return true;
|
||||
const dns = settingsForm.value.dns;
|
||||
return Boolean(dns.credential_ref && dns.verification_token) &&
|
||||
new Date(dns.expires_at).getTime() > Date.now();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [settingsForm.value.dns.provider, settingsForm.value.dns.test_domain, settingsForm.value.dns.credentials],
|
||||
handleDNSProviderChange,
|
||||
{ deep: true, flush: "sync" },
|
||||
);
|
||||
|
||||
const defaultCertificatePath = "/ssl/{域名}/{证书类型}/cert.pem";
|
||||
@ -332,6 +346,7 @@ export function useDashboard() {
|
||||
credentials: {},
|
||||
credential_ref: "",
|
||||
verification_token: "",
|
||||
expires_at: "",
|
||||
test_report: null,
|
||||
},
|
||||
notification: {
|
||||
@ -379,6 +394,7 @@ export function useDashboard() {
|
||||
credentials: {},
|
||||
credential_ref: "",
|
||||
verification_token: "",
|
||||
expires_at: "",
|
||||
test_domain: "",
|
||||
test_report: null,
|
||||
},
|
||||
@ -696,13 +712,18 @@ export function useDashboard() {
|
||||
}
|
||||
|
||||
function handleDNSProviderChange() {
|
||||
dnsRequestVersion += 1;
|
||||
dnsTesting.value = false;
|
||||
const dns = settingsForm.value.dns;
|
||||
const saved = settings.value?.dns;
|
||||
dnsTestError.value = "";
|
||||
dns.credential_ref = "";
|
||||
dns.verification_token = "";
|
||||
dns.expires_at = "";
|
||||
dns.test_report = null;
|
||||
if (dns.provider && dns.provider === saved?.provider && saved.credential_configured) {
|
||||
const hasNewInput = Boolean(dns.test_domain.trim()) ||
|
||||
Object.values(dns.credentials || {}).some((value) => String(value || "").trim());
|
||||
if (!hasNewInput && dns.provider && dns.provider === saved?.provider && saved.credential_configured) {
|
||||
dns.verification_state = saved.verification_state || "verified";
|
||||
return;
|
||||
}
|
||||
@ -715,6 +736,7 @@ export function useDashboard() {
|
||||
}
|
||||
|
||||
async function testDNS() {
|
||||
handleDNSProviderChange();
|
||||
const dns = settingsForm.value.dns;
|
||||
const provider = selectedDNSProvider.value;
|
||||
dnsTestError.value = "";
|
||||
@ -740,6 +762,8 @@ export function useDashboard() {
|
||||
return;
|
||||
}
|
||||
dnsTesting.value = true;
|
||||
dns.verification_state = "pending";
|
||||
const requestVersion = dnsRequestVersion;
|
||||
try {
|
||||
const response = await fetchAdmin("/api/v1/dns/test", {
|
||||
method: "POST",
|
||||
@ -751,22 +775,25 @@ export function useDashboard() {
|
||||
}),
|
||||
});
|
||||
const result = await response.json().catch(() => ({}));
|
||||
if (requestVersion !== dnsRequestVersion) return;
|
||||
dns.test_report = result.test_report || null;
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || "DNS API 在线测试失败");
|
||||
}
|
||||
dns.credential_ref = result.credential_ref || "";
|
||||
dns.verification_token = result.verification_token || "";
|
||||
dns.expires_at = result.expires_at || "";
|
||||
dns.verification_state = "verified";
|
||||
dns.credential_configured = true;
|
||||
notice.value = "DNS API 在线测试通过,可以保存系统设置";
|
||||
} catch (err) {
|
||||
if (requestVersion !== dnsRequestVersion) return;
|
||||
dns.credential_ref = "";
|
||||
dns.verification_token = "";
|
||||
dns.verification_state = "pending";
|
||||
dnsTestError.value = err.message || "DNS API 在线测试失败";
|
||||
} finally {
|
||||
dnsTesting.value = false;
|
||||
if (requestVersion === dnsRequestVersion) dnsTesting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@ -778,11 +805,15 @@ export function useDashboard() {
|
||||
}
|
||||
|
||||
function closeSettings() {
|
||||
if (!settingsSaving.value) showSettings.value = false;
|
||||
if (!settingsSaving.value) {
|
||||
handleDNSProviderChange();
|
||||
showSettings.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
settingsError.value = "";
|
||||
verificationClock.value = Date.now();
|
||||
if (!dnsReadyToSave.value) {
|
||||
settingsError.value = "DNS 服务商配置尚未通过在线测试,请先完成测试";
|
||||
return;
|
||||
@ -1061,8 +1092,15 @@ export function useDashboard() {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData);
|
||||
onMounted(() => {
|
||||
loadData();
|
||||
verificationTimer = window.setInterval(() => {
|
||||
verificationClock.value = Date.now();
|
||||
}, 1000);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
dnsRequestVersion += 1;
|
||||
if (verificationTimer) window.clearInterval(verificationTimer);
|
||||
document.body.style.overflow = previousBodyOverflow;
|
||||
});
|
||||
|
||||
|
||||
83
frontend/src/views/Dashboard/Dashboard.test.js
Normal file
83
frontend/src/views/Dashboard/Dashboard.test.js
Normal file
@ -0,0 +1,83 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { nextTick } from "vue";
|
||||
import { useDashboard } from "./Dashboard.js";
|
||||
import { aliyunProvider, deferred, mountComposable, verifiedResponse } from "../../test-support/mount.js";
|
||||
|
||||
async function setup(t, request) {
|
||||
const { state } = await mountComposable(t, useDashboard, async (path) => {
|
||||
if (path === "/api/v1/dns/test") return request();
|
||||
if (path.endsWith("/dns/providers")) return Response.json({ items: [aliyunProvider] });
|
||||
if (path.endsWith("/settings")) return Response.json({ dns: { provider: "aliyun", credential_configured: true, verification_state: "verified" } });
|
||||
return Response.json({ items: [] });
|
||||
});
|
||||
state.openSettings();
|
||||
await nextTick();
|
||||
return state;
|
||||
}
|
||||
|
||||
async function fill(state) {
|
||||
state.settingsForm.value.dns.credentials = { access_key_id: "fake-key" };
|
||||
state.settingsForm.value.dns.test_domain = "example.test";
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
test("已保存凭据不变可保存,输入新凭据必须重新验证", async (t) => {
|
||||
const state = await setup(t, () => verifiedResponse());
|
||||
assert.equal(state.dnsReadyToSave.value, true);
|
||||
await fill(state);
|
||||
assert.equal(state.dnsReadyToSave.value, false);
|
||||
await state.testDNS();
|
||||
assert.equal(state.dnsReadyToSave.value, true);
|
||||
state.settingsForm.value.dns.credentials.access_key_id = "changed-key";
|
||||
assert.equal(state.dnsReadyToSave.value, false);
|
||||
});
|
||||
|
||||
test("管理设置不接受旧输入的在途 DNS 响应", async (t) => {
|
||||
const pending = deferred();
|
||||
const state = await setup(t, () => pending.promise);
|
||||
await fill(state);
|
||||
const task = state.testDNS();
|
||||
state.settingsForm.value.dns.test_domain = "other.test";
|
||||
await nextTick();
|
||||
pending.resolve(verifiedResponse());
|
||||
await task;
|
||||
assert.equal(state.dnsReadyToSave.value, false);
|
||||
assert.equal(state.settingsForm.value.dns.verification_token, "");
|
||||
});
|
||||
|
||||
test("管理设置拒绝过期验证令牌", async (t) => {
|
||||
const state = await setup(t, () => Response.json({ credential_ref: "secret://test/only", verification_token: "expired", expires_at: "2000-01-01T00:00:00Z" }));
|
||||
await fill(state);
|
||||
await state.testDNS();
|
||||
assert.equal(state.dnsReadyToSave.value, false);
|
||||
});
|
||||
|
||||
test("关闭设置后不显示旧 DNS 请求的成功提示", async (t) => {
|
||||
const pending = deferred();
|
||||
const state = await setup(t, () => pending.promise);
|
||||
await fill(state);
|
||||
const task = state.testDNS();
|
||||
state.closeSettings();
|
||||
pending.resolve(verifiedResponse());
|
||||
await task;
|
||||
assert.equal(state.settingsForm.value.dns.verification_token, "");
|
||||
assert.equal(state.notice.value, "");
|
||||
});
|
||||
|
||||
test("管理设置新请求成功后旧失败不能删除令牌", async (t) => {
|
||||
const first = deferred();
|
||||
const second = deferred();
|
||||
let count = 0;
|
||||
const state = await setup(t, () => (++count === 1 ? first.promise : second.promise));
|
||||
await fill(state);
|
||||
const oldTask = state.testDNS();
|
||||
const newTask = state.testDNS();
|
||||
second.resolve(verifiedResponse("new-token"));
|
||||
await newTask;
|
||||
first.reject(new Error("旧请求失败"));
|
||||
await oldTask;
|
||||
assert.equal(state.settingsForm.value.dns.verification_token, "new-token");
|
||||
assert.equal(state.dnsTestError.value, "");
|
||||
assert.equal(state.dnsReadyToSave.value, true);
|
||||
});
|
||||
@ -41,6 +41,7 @@ export function useSetupWizard(props, emit) {
|
||||
const emailAutofillElement = ref(null);
|
||||
let emailVisibleEdited = false;
|
||||
let verificationTimer;
|
||||
let dnsRequestVersion = 0;
|
||||
let emailAutofillTimer;
|
||||
const targetType = ref(props.status.web_public_ip ? "ip" : "hostname");
|
||||
const form = ref({
|
||||
@ -141,6 +142,8 @@ export function useSetupWizard(props, emit) {
|
||||
});
|
||||
|
||||
function invalidateDNSVerification() {
|
||||
dnsRequestVersion += 1;
|
||||
dnsTesting.value = false;
|
||||
dnsVerification.value = null;
|
||||
dnsTestError.value = "";
|
||||
}
|
||||
@ -415,6 +418,7 @@ export function useSetupWizard(props, emit) {
|
||||
}
|
||||
|
||||
dnsTesting.value = true;
|
||||
const requestVersion = dnsRequestVersion;
|
||||
try {
|
||||
const response = await fetch("/api/v1/setup/dns/test", {
|
||||
method: "POST",
|
||||
@ -426,15 +430,17 @@ export function useSetupWizard(props, emit) {
|
||||
}),
|
||||
});
|
||||
const result = await response.json().catch(() => ({}));
|
||||
if (requestVersion !== dnsRequestVersion) return;
|
||||
if (result.test_report) {
|
||||
dnsVerification.value = { test_report: result.test_report };
|
||||
}
|
||||
if (!response.ok) throw new Error(result.error || "DNS API 在线验证失败");
|
||||
dnsVerification.value = result;
|
||||
} catch (err) {
|
||||
if (requestVersion !== dnsRequestVersion) return;
|
||||
dnsTestError.value = err.message || "DNS API 在线验证失败";
|
||||
} finally {
|
||||
dnsTesting.value = false;
|
||||
if (requestVersion === dnsRequestVersion) dnsTesting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@ -773,10 +779,11 @@ export function useSetupWizard(props, emit) {
|
||||
dnsCredentials.value = {};
|
||||
invalidateDNSVerification();
|
||||
},
|
||||
{ flush: "sync" },
|
||||
);
|
||||
|
||||
watch(dnsCredentials, invalidateDNSVerification, { deep: true });
|
||||
watch(dnsTestDomain, invalidateDNSVerification);
|
||||
watch(dnsCredentials, invalidateDNSVerification, { deep: true, flush: "sync" });
|
||||
watch(dnsTestDomain, invalidateDNSVerification, { flush: "sync" });
|
||||
|
||||
onMounted(() => {
|
||||
loadDNSProviders();
|
||||
@ -789,6 +796,7 @@ export function useSetupWizard(props, emit) {
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
invalidateDNSVerification();
|
||||
if (verificationTimer) window.clearInterval(verificationTimer);
|
||||
if (emailAutofillTimer) window.clearInterval(emailAutofillTimer);
|
||||
});
|
||||
|
||||
77
frontend/src/views/SetupWizard/SetupWizard.test.js
Normal file
77
frontend/src/views/SetupWizard/SetupWizard.test.js
Normal file
@ -0,0 +1,77 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { nextTick } from "vue";
|
||||
import { useSetupWizard } from "./SetupWizard.js";
|
||||
import { aliyunProvider, deferred, mountComposable, verifiedResponse } from "../../test-support/mount.js";
|
||||
|
||||
async function setup(t, request) {
|
||||
const { state, unmount } = await mountComposable(t, () => useSetupWizard({ status: { dns_provider: "aliyun" } }, () => {}),
|
||||
async (path) => path.endsWith("/providers") ? Response.json({ items: [aliyunProvider] }) : request());
|
||||
state.dnsCredentials.value = { access_key_id: "fake-key" };
|
||||
state.dnsTestDomain.value = "example.test";
|
||||
await nextTick();
|
||||
return { state, unmount };
|
||||
}
|
||||
|
||||
test("初始化不接受输入修改前的在途 DNS 响应", async (t) => {
|
||||
const pending = deferred();
|
||||
const { state } = await setup(t, () => pending.promise);
|
||||
const task = state.testDNS();
|
||||
state.dnsCredentials.value.access_key_id = "changed-key";
|
||||
await nextTick();
|
||||
pending.resolve(verifiedResponse());
|
||||
await task;
|
||||
assert.equal(state.dnsVerificationValid.value, false);
|
||||
assert.equal(state.dnsVerification.value, null);
|
||||
});
|
||||
|
||||
test("初始化仅接受最新请求,旧失败不能覆盖新成功", async (t) => {
|
||||
const first = deferred();
|
||||
const second = deferred();
|
||||
let count = 0;
|
||||
const { state } = await setup(t, () => (++count === 1 ? first.promise : second.promise));
|
||||
const oldTask = state.testDNS();
|
||||
const newTask = state.testDNS();
|
||||
second.resolve(verifiedResponse("new-token"));
|
||||
await newTask;
|
||||
first.reject(new Error("旧请求失败"));
|
||||
await oldTask;
|
||||
assert.equal(state.dnsVerification.value.verification_token, "new-token");
|
||||
assert.equal(state.dnsTestError.value, "");
|
||||
});
|
||||
|
||||
test("初始化当前 DNS 成功有效,修改域名立即失效", async (t) => {
|
||||
const { state } = await setup(t, () => verifiedResponse());
|
||||
await state.testDNS();
|
||||
assert.equal(state.dnsVerificationValid.value, true);
|
||||
state.dnsTestDomain.value = "other.test";
|
||||
assert.equal(state.dnsVerificationValid.value, false);
|
||||
});
|
||||
|
||||
test("初始化卸载后不恢复旧 DNS 验证结果", async (t) => {
|
||||
const pending = deferred();
|
||||
const { state, unmount } = await setup(t, () => pending.promise);
|
||||
const task = state.testDNS();
|
||||
unmount();
|
||||
pending.resolve(verifiedResponse());
|
||||
await task;
|
||||
assert.equal(state.dnsVerification.value, null);
|
||||
});
|
||||
|
||||
test("初始化旧请求结束不清除新请求的加载状态", async (t) => {
|
||||
const first = deferred();
|
||||
const second = deferred();
|
||||
let count = 0;
|
||||
const { state } = await setup(t, () => (++count === 1 ? first.promise : second.promise));
|
||||
const oldTask = state.testDNS();
|
||||
state.dnsTestDomain.value = "other.test";
|
||||
const newTask = state.testDNS();
|
||||
first.resolve(verifiedResponse());
|
||||
await oldTask;
|
||||
assert.equal(state.dnsTesting.value, true);
|
||||
assert.equal(state.dnsVerification.value, null);
|
||||
second.resolve(verifiedResponse("current"));
|
||||
await newTask;
|
||||
assert.equal(state.dnsTesting.value, false);
|
||||
assert.equal(state.dnsVerification.value.verification_token, "current");
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user