diff --git a/backend/internal/notification/recovery_warning.go b/backend/internal/notification/recovery_warning.go new file mode 100644 index 0000000..1d53d94 --- /dev/null +++ b/backend/internal/notification/recovery_warning.go @@ -0,0 +1,38 @@ +package notification + +import ( + "strings" + + "git.pcux.cn/xuans/Scert/backend/internal/deployment" +) + +// 恢复警告不受明细开关或摘要条数限制,不包含服务器名称及远端输出。 +func recoveryWarning(event Event) string { + const warning = "需要人工核查:部署恢复尚未确认或部署锁需要处理,勿直接重试或删除锁。请管理员核查服务器文件、服务和站点状态。" + switch strings.ToLower(strings.TrimSpace(event.Deployment.RecoveryStatus)) { + case "unconfirmed", "failed", "recovery_required": + return warning + } + for _, result := range event.Deployment.Results { + switch result.FailureCode { + case deployment.FailureRollback, deployment.FailureRuntimeUnknown, + deployment.FailureRecoveryPending, deployment.FailureRecoveryUnknown, + deployment.FailureLock, deployment.FailureLockRelease: + return warning + } + if hasRecoveryWarning(result.Message) { + return warning + } + } + if hasRecoveryWarning(event.Error) { + return warning + } + return "" +} + +// 兼容未携带结构化诊断的历史任务。 +func hasRecoveryWarning(message string) bool { + return strings.Contains(message, "部署锁") || + (strings.Contains(message, "恢复") && + (strings.Contains(message, "未确认") || strings.Contains(message, "待核查"))) +} diff --git a/backend/internal/notification/security.go b/backend/internal/notification/security.go index 7c2b99f..1fb7734 100644 --- a/backend/internal/notification/security.go +++ b/backend/internal/notification/security.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "html" "net/mail" ) @@ -40,7 +39,7 @@ func (s *Service) SendSecurityCode(ctx context.Context, recipient, code string) From: address{Name: fromName, Email: from}, To: []address{{Email: recipient}}, Subject: "[Scert] 超级管理员密码重置验证", - Text: text, HTML: "
" + html.EscapeString(text) + "
", + Text: text, HTML: buildSecurityHTML(code), CC: []address{}, BCC: []address{}, Attrs: []any{}, } if s.config.UsesSmailSystemAccount() { diff --git a/backend/internal/notification/security_template.go b/backend/internal/notification/security_template.go new file mode 100644 index 0000000..dc731e3 --- /dev/null +++ b/backend/internal/notification/security_template.go @@ -0,0 +1,34 @@ +package notification + +import "strings" + +func buildSecurityHTML(code string) string { + var builder strings.Builder + builder.WriteString(` + + + +
+
+
+
SCERT 证书管理服务
+

超级管理员密码重置验证

+
+
+

有人正在部署服务器上重置 Scert 超级管理员密码。

+
+
本次验证码
+
`) + builder.WriteString(escapeHTML(code)) + builder.WriteString(`
+
+

请在服务器本机重置终端输入。验证码仅在当前本机重置会话中有效,有效期不超过两分钟。

+
如果不是您本人操作,请勿提供此验证码,并检查服务器访问权限。
+
+
此邮件由 Scert 自动发送,请勿直接回复。
+
+
+ +`) + return builder.String() +} diff --git a/backend/internal/notification/service.go b/backend/internal/notification/service.go index d8e3edd..268b0f7 100644 --- a/backend/internal/notification/service.go +++ b/backend/internal/notification/service.go @@ -402,7 +402,11 @@ func (s *Service) buildPayload(event Event) (mailPayload, error) { status := eventStatus(event.Status) domainName := strings.TrimSpace(event.Domain) if domainName == "" { - domainName = strings.TrimSpace(event.DomainID) + for _, domain := range event.Domains { + if domainName = strings.TrimSpace(domain); domainName != "" { + break + } + } } if domainName == "" { domainName = "未指定域名" diff --git a/backend/internal/notification/template.go b/backend/internal/notification/template.go index 8366c52..6072820 100644 --- a/backend/internal/notification/template.go +++ b/backend/internal/notification/template.go @@ -21,6 +21,7 @@ type mailContent struct { certificateSerial string managementURL string problem string + recoveryWarning string deployment deployment.Report includeDeployment bool hasDeployment bool @@ -39,6 +40,7 @@ func buildMailContent(event Event, title, status, domainName string, cfg config. status: status, statusColor: mailStatusColor(status), statusSummary: mailStatusSummary(event, title, status), + recoveryWarning: recoveryWarning(event), domain: domainName, domains: append([]string(nil), event.Domains...), deployment: event.Deployment, @@ -86,6 +88,9 @@ func buildTextMail(content mailContent) string { if content.problem != "" { lines = append(lines, "", "问题概述:", content.problem) } + if content.recoveryWarning != "" { + lines = append(lines, "", content.recoveryWarning) + } if content.includeDeployment && len(content.deployment.Results) > 0 { lines = append(lines, "", "处理结果:") for _, result := range content.deployment.Results { @@ -103,7 +108,7 @@ func buildTextMail(content mailContent) string { func buildHTMLMail(content mailContent) string { var builder strings.Builder - builder.WriteString(``) + builder.WriteString(``) builder.WriteString(`
`) builder.WriteString(`
SCERT 证书管理服务
证书管理通知
`) builder.WriteString(`
`) @@ -131,6 +136,9 @@ func buildHTMLMail(content mailContent) string { if content.hasDeployment { builder.WriteString(`
部署概况
` + escapeHTML(deploymentSummary(content.deployment)) + `
`) } + if content.recoveryWarning != "" { + builder.WriteString(`
` + escapeHTML(content.recoveryWarning) + `
`) + } if content.includeDeployment && len(content.deployment.Results) > 0 { builder.WriteString(`
处理结果
`) for _, result := range content.deployment.Results { @@ -165,7 +173,7 @@ func mailStatusSummary(event Event, title, status string) string { if event.Deployment.Total > 0 && event.Deployment.Failed > 0 { return fmt.Sprintf("%s未完成,共 %d 台服务器失败", title, event.Deployment.Failed) } - if event.Deployment.Total > 0 && event.Deployment.Success == event.Deployment.Total { + if status == eventStatus("success") && event.Deployment.Total > 0 && event.Deployment.Success == event.Deployment.Total { return fmt.Sprintf("%s已完成,共 %d 台服务器成功", title, event.Deployment.Success) } if status == "成功" { diff --git a/backend/internal/notification/template_safety_test.go b/backend/internal/notification/template_safety_test.go new file mode 100644 index 0000000..3cb3db1 --- /dev/null +++ b/backend/internal/notification/template_safety_test.go @@ -0,0 +1,183 @@ +package notification + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.pcux.cn/xuans/Scert/backend/internal/deployment" + htmlparser "golang.org/x/net/html" +) + +func TestFailedTaskCannotHaveCompletedSummary(t *testing.T) { + for _, status := range []string{"failed", "error", "unknown"} { + t.Run(status, func(t *testing.T) { + payload, err := New(newTestConfig("http://mail.example.test"), nil).buildPayload(Event{ + Status: status, + Domain: "example.test", + Deployment: deployment.Report{Total: 2, Success: 2, Status: "success"}, + }) + if err != nil { + t.Fatal(err) + } + for _, body := range []string{payload.Text, payload.HTML} { + if strings.Contains(body, "已完成") { + t.Fatal("失败或未知任务不能显示完成摘要") + } + } + }) + } +} + +func TestRecoveryWarningSurvivesHiddenDetails(t *testing.T) { + for _, report := range []deployment.Report{ + {RecoveryStatus: "unconfirmed"}, + {RecoveryStatus: "failed"}, + {RecoveryStatus: "recovery_required"}, + {Results: []deployment.Result{{Status: "failed", Message: "已恢复旧文件,但服务或站点恢复状态未确认,已保留部署锁"}}}, + } { + cfg := newTestConfig("http://mail.example.test") + cfg.IncludeDeploymentDetails = false + payload, err := New(cfg, nil).buildPayload(Event{Status: "failed", Domain: "example.test", Deployment: report}) + if err != nil { + t.Fatal(err) + } + for _, body := range []string{payload.Text, payload.HTML} { + if !strings.Contains(body, "勿直接重试或删除锁") { + t.Fatal("隐藏部署明细不能隐藏恢复安全提示") + } + } + } +} + +func TestRecoveryWarningUsesFailureCodes(t *testing.T) { + for _, code := range []deployment.FailureCode{ + deployment.FailureRollback, deployment.FailureRuntimeUnknown, + deployment.FailureRecoveryPending, deployment.FailureRecoveryUnknown, + deployment.FailureLock, deployment.FailureLockRelease, + } { + t.Run(string(code), func(t *testing.T) { + cfg := newTestConfig("http://mail.example.test") + cfg.IncludeDeploymentDetails = false + payload, err := New(cfg, nil).buildPayload(Event{ + Status: "failed", Domain: "example.test", Error: "primary failure", + Deployment: deployment.Report{Results: []deployment.Result{ + {Status: "failed", Message: "first failure"}, + {Status: "failed", Message: "second failure"}, + {Server: "hidden-server", Status: "failed", FailureCode: code}, + }}, + }) + if err != nil { + t.Fatal(err) + } + for _, body := range []string{payload.Text, payload.HTML} { + if !strings.Contains(body, "勿直接重试或删除锁") || strings.Contains(body, "hidden-server") { + t.Fatal("恢复警告必须独立展示且不能暴露已隐藏的服务器名称") + } + } + }) + } +} + +func TestVerifiedRecoveryDoesNotRequireManualUnlock(t *testing.T) { + event := Event{Status: "failed", Deployment: deployment.Report{ + RecoveryStatus: "success", + Results: []deployment.Result{{FailureCode: deployment.FailureRecoveryRestored}}, + }} + if recoveryWarning(event) != "" { + t.Fatal("已确认恢复的任务不应提示恢复未确认") + } + event = Event{Status: "failed", Error: "已保留部署锁"} + if recoveryWarning(event) == "" { + t.Fatal("仅任务错误携带恢复信息时也应提示核查") + } +} + +func TestMailUsesCertificateDomainInsteadOfInternalID(t *testing.T) { + for _, domains := range [][]string{{" ", "example.test"}, nil} { + payload, err := New(newTestConfig("http://mail.example.test"), nil).buildPayload(Event{ + Status: "success", DomainID: "internal-domain-id", Domains: domains, + }) + if err != nil { + t.Fatal(err) + } + expected := "未指定域名" + if len(domains) > 0 { + expected = "example.test" + } + for _, body := range []string{payload.Subject, payload.Text, payload.HTML} { + if strings.Contains(body, "internal-domain-id") || !strings.Contains(body, expected) { + t.Fatal("用户邮件应显示证书域名而非内部记录编号") + } + } + } +} + +func TestAllMailTemplatesHaveValidBodyStyles(t *testing.T) { + for _, eventType := range []string{"certificate_issue", "certificate_renewal", "management_renewal", "certificate_import", "unknown"} { + for _, status := range []string{"success", "failed", "error"} { + payload, err := New(newTestConfig("http://mail.example.test"), nil).buildPayload(Event{ + Type: eventType, Status: status, Domain: "example.test", + }) + if err != nil { + t.Fatal(err) + } + assertMailBodyStyle(t, payload.HTML) + } + } + assertMailBodyStyle(t, buildSecurityHTML("01234567")) + if strings.Contains(buildSecurityHTML(``), "