diff --git a/backend/internal/dnsprovider/aliyun_authorization.go b/backend/internal/dnsprovider/aliyun_authorization.go index f5eb3b3..6abd8a6 100644 --- a/backend/internal/dnsprovider/aliyun_authorization.go +++ b/backend/internal/dnsprovider/aliyun_authorization.go @@ -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 权限验证未通过,请检查网络、凭据和目标域名权限;不要将未确认状态视为已授权" } diff --git a/backend/internal/httpapi/aliyun_scope_api.go b/backend/internal/httpapi/aliyun_scope_api.go index 3e8e18f..b856df7 100644 --- a/backend/internal/httpapi/aliyun_scope_api.go +++ b/backend/internal/httpapi/aliyun_scope_api.go @@ -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 凭据引用未配置") diff --git a/backend/internal/httpapi/operation_timeout.go b/backend/internal/httpapi/operation_timeout.go new file mode 100644 index 0000000..36d08c7 --- /dev/null +++ b/backend/internal/httpapi/operation_timeout.go @@ -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() +} diff --git a/backend/internal/httpapi/renewal_api.go b/backend/internal/httpapi/renewal_api.go index fa6ea83..52c4395 100644 --- a/backend/internal/httpapi/renewal_api.go +++ b/backend/internal/httpapi/renewal_api.go @@ -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 { diff --git a/backend/internal/httpapi/report_safety_test.go b/backend/internal/httpapi/report_safety_test.go new file mode 100644 index 0000000..6f4a853 --- /dev/null +++ b/backend/internal/httpapi/report_safety_test.go @@ -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("脱敏必须保留写入成功但清理失败的验收事实") + } +} diff --git a/backend/internal/httpapi/request_safety_test.go b/backend/internal/httpapi/request_safety_test.go new file mode 100644 index 0000000..7a7c98c --- /dev/null +++ b/backend/internal/httpapi/request_safety_test.go @@ -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) + } + } +} diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go index 2da76c8..6492bce 100644 --- a/backend/internal/httpapi/server.go +++ b/backend/internal/httpapi/server.go @@ -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) diff --git a/backend/internal/httpapi/setup.go b/backend/internal/httpapi/setup.go index ca77e72..21a48df 100644 --- a/backend/internal/httpapi/setup.go +++ b/backend/internal/httpapi/setup.go @@ -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( diff --git a/frontend/package.json b/frontend/package.json index c5e8048..f7a0c2e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" diff --git a/frontend/src/test-support/mount.js b/frontend/src/test-support/mount.js new file mode 100644 index 0000000..18c8a0d --- /dev/null +++ b/frontend/src/test-support/mount.js @@ -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 }; +} diff --git a/frontend/src/views/Dashboard/Dashboard.js b/frontend/src/views/Dashboard/Dashboard.js index 4f9f39d..32cb586 100644 --- a/frontend/src/views/Dashboard/Dashboard.js +++ b/frontend/src/views/Dashboard/Dashboard.js @@ -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; }); diff --git a/frontend/src/views/Dashboard/Dashboard.test.js b/frontend/src/views/Dashboard/Dashboard.test.js new file mode 100644 index 0000000..a4c5951 --- /dev/null +++ b/frontend/src/views/Dashboard/Dashboard.test.js @@ -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); +}); diff --git a/frontend/src/views/SetupWizard/SetupWizard.js b/frontend/src/views/SetupWizard/SetupWizard.js index 87379e9..5ab6614 100644 --- a/frontend/src/views/SetupWizard/SetupWizard.js +++ b/frontend/src/views/SetupWizard/SetupWizard.js @@ -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); }); diff --git a/frontend/src/views/SetupWizard/SetupWizard.test.js b/frontend/src/views/SetupWizard/SetupWizard.test.js new file mode 100644 index 0000000..25c68a8 --- /dev/null +++ b/frontend/src/views/SetupWizard/SetupWizard.test.js @@ -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"); +});