`)
@@ -131,6 +136,9 @@ func buildHTMLMail(content mailContent) string {
if content.hasDeployment {
builder.WriteString(`
`)
+ }
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(``), "