package notification import ( "context" "encoding/json" "io" "net/http" "net/http/httptest" "strconv" "strings" "testing" "time" "git.pcux.cn/xuans/Scert/backend/internal/config" "git.pcux.cn/xuans/Scert/backend/internal/deployment" ) type credentialReaderStub struct { values map[string]string } func (s credentialReaderStub) Get(context.Context, string) (map[string]string, error) { return s.values, nil } func newTestConfig(endpoint string) config.NotificationConfig { return config.NotificationConfig{ HTTPEnabled: true, Endpoint: endpoint, HTTPPath: "/api/email/send", HTTPLoginPath: "/api/login", HTTPAuthMode: "none", EventMode: "detailed", SenderName: "Scert", SenderEmail: "admin@example.com", Recipients: []string{"receiver@example.com"}, TimeoutSecs: 2, RetryCount: 0, RetryIntervalsSecs: []int{}, } } func TestSendBuildsSmailPayload(t *testing.T) { var payload map[string]any server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { if request.Method != http.MethodPost || request.URL.Path != "/api/email/send" { t.Fatalf("请求方法或路径错误:%s %s", request.Method, request.URL.Path) } if request.Header.Get("Content-Type") != "application/json; charset=utf-8" { t.Fatalf("通知请求编码声明错误:%q", request.Header.Get("Content-Type")) } if request.Header.Get("X-Scert-Request-Id") == "" || request.Header.Get("X-Scert-Timestamp") == "" { t.Fatal("缺少通知安全请求头") } body, err := io.ReadAll(request.Body) if err != nil { t.Fatal(err) } if err := json.Unmarshal(body, &payload); err != nil { t.Fatal(err) } if !strings.Contains(string(body), "证书续期") { t.Fatalf("通知请求未使用 UTF-8 编码:%q", string(body)) } writer.WriteHeader(http.StatusNoContent) })) defer server.Close() service := New(newTestConfig(server.URL), nil) service.requestID = func() string { return "request-id" } service.now = func() time.Time { return time.Unix(1700000000, 0).UTC() } when := time.Date(2026, 8, 30, 12, 0, 0, 0, time.UTC) err := service.Send(context.Background(), Event{ Type: "certificate_renewal", Status: "success", Domain: "example.com", Domains: []string{"example.com", "www.example.com"}, ExpiresAt: &when, ManagementURL: "https://scert.example.com", }) if err != nil { t.Fatalf("发送通知失败:%v", err) } if payload["subject"] == "" || !strings.Contains(payload["text"].(string), "证书续期") { t.Fatalf("通知正文未正确生成:%v", payload) } to := payload["to"].([]any) if len(to) != 1 || to[0].(map[string]any)["email"] != "receiver@example.com" { t.Fatalf("收件人未正确生成:%v", payload["to"]) } } func TestSendSupportsBearerAndRetries(t *testing.T) { attempts := 0 var authorization string server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { attempts++ authorization = request.Header.Get("Authorization") if attempts < 3 { writer.WriteHeader(http.StatusBadGateway) return } writer.WriteHeader(http.StatusOK) })) defer server.Close() cfg := newTestConfig(server.URL) cfg.HTTPAuthMode = "bearer" cfg.HTTPCredentialRef = "secret://mail-token" cfg.RetryCount = 2 cfg.RetryIntervalsSecs = []int{1, 1} service := New(cfg, credentialReaderStub{values: map[string]string{"token": "test-token"}}) service.sleep = func(context.Context, time.Duration) error { return nil } if err := service.Send(context.Background(), Event{Type: "certificate_issue", Status: "success", Domain: "example.com"}); err != nil { t.Fatalf("重试后发送通知失败:%v", err) } if attempts != 3 || authorization != "Bearer test-token" { t.Fatalf("Bearer 或重试次数错误:attempts=%d authorization=%q", attempts, authorization) } } func TestSendSupportsHMACAndProtectsHeaders(t *testing.T) { var receivedSignature string var receivedTimestamp string var receivedRequestID string var receivedBody []byte server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { receivedSignature = request.Header.Get("X-Scert-Signature") receivedTimestamp = request.Header.Get("X-Scert-Timestamp") receivedRequestID = request.Header.Get("X-Scert-Request-Id") receivedBody, _ = io.ReadAll(request.Body) writer.WriteHeader(http.StatusCreated) })) defer server.Close() cfg := newTestConfig(server.URL) cfg.HTTPAuthMode = "hmac_sha256" cfg.HTTPCredentialRef = "secret://mail-hmac" service := New(cfg, credentialReaderStub{values: map[string]string{"secret": "hmac-secret"}}) service.requestID = func() string { return "fixed-request" } service.now = func() time.Time { return time.Unix(1700000000, 0).UTC() } if err := service.Send(context.Background(), Event{Type: "certificate_import", Status: "unknown", Domain: "example.com"}); err != nil { t.Fatalf("HMAC 通知发送失败:%v", err) } expected := "sha256=" + sign("hmac-secret", receivedTimestamp, receivedRequestID, receivedBody) if receivedSignature != expected { t.Fatalf("HMAC 签名错误:got=%q want=%q", receivedSignature, expected) } cfg.HTTPAuthMode = "none" cfg.HTTPHeaders = map[string]string{"Content-Type": "text/plain"} service = New(cfg, nil) service.sleep = func(context.Context, time.Duration) error { return nil } if err := service.Send(context.Background(), Event{Status: "success", Domain: "example.com"}); err == nil { t.Fatal("覆盖安全请求头时应失败") } } func TestSendSupportsSessionLogin(t *testing.T) { var loginPayload map[string]string var sendCookie string var csrfToken string var origin string server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { switch request.URL.Path { case "/api/login": if request.Method != http.MethodPost { t.Fatalf("登录请求方法错误:%s", request.Method) } body, err := io.ReadAll(request.Body) if err != nil { t.Fatal(err) } if err := json.Unmarshal(body, &loginPayload); err != nil { t.Fatal(err) } if request.Header.Get("Content-Type") != "application/json; charset=utf-8" { t.Fatalf("登录请求编码声明错误:%q", request.Header.Get("Content-Type")) } writer.Header().Add("Set-Cookie", "session=test-session; Path=/; HttpOnly") writer.Header().Add("Set-Cookie", "smail_csrf=test-csrf; Path=/") writer.Header().Set("Content-Type", "application/json") _, _ = writer.Write([]byte(`{"errorNo":0,"errorMsg":"","data":{"account":"xuans"}}`)) case "/api/email/send": sendCookie = request.Header.Get("Cookie") csrfToken = request.Header.Get("X-CSRF-Token") origin = request.Header.Get("Origin") writer.WriteHeader(http.StatusAccepted) default: writer.WriteHeader(http.StatusNotFound) } })) defer server.Close() cfg := newTestConfig(server.URL) cfg.HTTPAuthMode = "session" cfg.HTTPCredentialRef = "secret://mail-session" service := New(cfg, credentialReaderStub{values: map[string]string{ "account": "xuans", "password": " test-password ", }}) if err := service.Send(context.Background(), Event{Type: "certificate_issue", Status: "success", Domain: "example.com"}); err != nil { t.Fatalf("会话认证通知发送失败:%v", err) } if loginPayload["account"] != "xuans" || loginPayload["password"] != " test-password " { t.Fatalf("登录请求字段错误:%v", loginPayload) } if !strings.Contains(sendCookie, "session=test-session") || csrfToken != "test-csrf" { t.Fatalf("发送请求未携带登录会话:cookie=%q csrf=%q", sendCookie, csrfToken) } if origin != server.URL { t.Fatalf("发送请求来源错误:%q", origin) } } func TestSendRejectsBusinessFailure(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { writer.Header().Set("Content-Type", "application/json; charset=utf-8") _, _ = writer.Write([]byte(`{"errorNo":100,"errorMsg":"参数错误"}`)) })) defer server.Close() service := New(newTestConfig(server.URL), nil) err := service.Send(context.Background(), Event{Status: "success", Domain: "example.com"}) if err == nil || !strings.Contains(err.Error(), "业务失败") { t.Fatalf("业务错误响应应被拒绝:%v", err) } } func TestSendRefreshesExpiredSession(t *testing.T) { loginCount := 0 sendCount := 0 var cookies []string server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { switch request.URL.Path { case "/api/login": loginCount++ writer.Header().Add("Set-Cookie", "session=session-"+strconv.Itoa(loginCount)+"; Path=/; HttpOnly") writer.Header().Set("Content-Type", "application/json; charset=utf-8") _, _ = writer.Write([]byte(`{"errorNo":0,"errorMsg":""}`)) case "/api/email/send": sendCount++ cookies = append(cookies, request.Header.Get("Cookie")) if sendCount == 1 { writer.WriteHeader(http.StatusForbidden) return } writer.WriteHeader(http.StatusNoContent) default: writer.WriteHeader(http.StatusNotFound) } })) defer server.Close() cfg := newTestConfig(server.URL) cfg.HTTPAuthMode = "session" cfg.HTTPCredentialRef = "secret://mail-session" cfg.RetryCount = 1 cfg.RetryIntervalsSecs = []int{1} service := New(cfg, credentialReaderStub{values: map[string]string{ "account": "xuans", "password": "test-password", }}) service.sleep = func(context.Context, time.Duration) error { return nil } if err := service.Send(context.Background(), Event{Status: "success", Domain: "example.com"}); err != nil { t.Fatalf("会话失效后重新登录仍发送失败:%v", err) } if loginCount != 2 || sendCount != 2 || len(cookies) != 2 { t.Fatalf("会话刷新次数错误:login=%d send=%d cookies=%v", loginCount, sendCount, cookies) } if cookies[0] != "session=session-1" || cookies[1] != "session=session-2" { t.Fatalf("未使用刷新后的会话:%v", cookies) } } func TestSendRejectsEmptySessionPasswordWithoutTrimmingValidPassword(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { writer.WriteHeader(http.StatusInternalServerError) })) defer server.Close() cfg := newTestConfig(server.URL) cfg.HTTPAuthMode = "session" cfg.HTTPCredentialRef = "secret://mail-session" service := New(cfg, credentialReaderStub{values: map[string]string{ "account": "xuans", "password": "", }}) err := service.Send(context.Background(), Event{Status: "success", Domain: "example.com"}) if err == nil || !strings.Contains(err.Error(), "必须包含账号和密码") { t.Fatalf("空密码应在发起登录请求前被拒绝:%v", err) } } func TestSessionLoginPreservesWhitespacePassword(t *testing.T) { var loginPayload map[string]string server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { if request.URL.Path != "/api/login" { writer.WriteHeader(http.StatusNoContent) return } body, err := io.ReadAll(request.Body) if err != nil { t.Fatal(err) } if err := json.Unmarshal(body, &loginPayload); err != nil { t.Fatal(err) } writer.Header().Add("Set-Cookie", "session=whitespace-session; Path=/; HttpOnly") writer.Header().Set("Content-Type", "application/json; charset=utf-8") _, _ = writer.Write([]byte(`{"errorNo":0,"errorMsg":""}`)) })) defer server.Close() cfg := newTestConfig(server.URL) cfg.HTTPAuthMode = "session" cfg.HTTPCredentialRef = "secret://mail-session" service := New(cfg, credentialReaderStub{values: map[string]string{ "account": "xuans", "password": " test-password ", }}) if err := service.Send(context.Background(), Event{Status: "success", Domain: "example.com"}); err != nil { t.Fatalf("保留空白密码的会话登录失败:%v", err) } if loginPayload["password"] != " test-password " { t.Fatalf("密码不应被 TrimSpace:%q", loginPayload["password"]) } } func TestSessionLoginReturnsBusinessMessageWithoutCredentials(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { writer.Header().Set("Content-Type", "application/json; charset=utf-8") _, _ = writer.Write([]byte(`{"errorNo":403,"errorMsg":"账号或密码错误"}`)) })) defer server.Close() cfg := newTestConfig(server.URL) cfg.HTTPAuthMode = "session" cfg.HTTPCredentialRef = "secret://mail-session" service := New(cfg, credentialReaderStub{values: map[string]string{ "account": "xuans", "password": "test-password", }}) err := service.Send(context.Background(), Event{Status: "success", Domain: "example.com"}) if err == nil || !strings.Contains(err.Error(), "错误码 403") || !strings.Contains(err.Error(), "账号或密码错误") { t.Fatalf("登录业务错误应包含安全诊断:%v", err) } if strings.Contains(err.Error(), "test-password") { t.Fatalf("登录错误不得包含密码:%v", err) } } func TestBuildMailContentSimplifiesDeploymentFailure(t *testing.T) { cfg := newTestConfig("http://mail.example.test") cfg.IncludeDeploymentDetails = true cfg.IncludeCertificateSerial = true cfg.IncludeManagementURL = true service := New(cfg, nil) payload, err := service.buildPayload(Event{ Type: "certificate_renewal", Status: "failed", Domain: "scert-multi-20260930.apmmk.cn", Domains: []string{ "scert-multi-20260930.apmmk.cn", }, CertificateSerial: "serial-123", ManagementURL: "https://scert.example.test/tasks/1", Deployment: deployment.Report{ Total: 2, Failed: 2, Status: "failed", Results: []deployment.Result{ {Server: "第一台生产测试服务器", Status: "failed", Message: "本次部署未完成,已恢复旧文件,但服务或站点恢复状态未确认,已保留部署锁"}, {Server: "第二台 SSL 推送测试服务器", Status: "failed", Message: "remote HTTPS connection failed: tls: failed to verify certificate: x509: certificate is valid for *.vcer.cn, vcer.cn, not scert-multi-20260930.apmmk.cn"}, }, }, Error: "部署后 HTTPS 校验失败:服务器 第二台 SSL 推送测试服务器 的 HTTPS 校验失败:重试 6 次后仍未通过:remote HTTPS connection failed: tls: failed to verify certificate: x509: certificate is valid for *.vcer.cn, vcer.cn, not scert-multi-20260930.apmmk.cn", }) if err != nil { t.Fatalf("构造失败通知失败:%v", err) } if !strings.Contains(payload.Subject, "证书续期失败") { t.Fatalf("邮件主题未突出失败状态:%q", payload.Subject) } for _, value := range []string{ "摘要:证书续期未完成,共 2 台服务器失败", "问题概述:", "第二台 SSL 推送测试服务器:HTTPS 证书域名不匹配", "处理结果:", "第一台生产测试服务器:失败,旧文件已恢复,但服务或站点状态尚未确认", "第二台 SSL 推送测试服务器:失败,HTTPS 证书域名不匹配", } { if !strings.Contains(payload.Text, value) { t.Fatalf("纯文本通知缺少关键信息 %q:%s", value, payload.Text) } } if strings.Contains(payload.Text, "certificate is valid for") || strings.Contains(payload.HTML, "certificate is valid for") { t.Fatal("通知不应暴露冗长的底层 TLS 错误") } if !strings.Contains(payload.HTML, "border-radius") || !strings.Contains(payload.HTML, "问题概述") || !strings.Contains(payload.HTML, "部署概况") { t.Fatalf("HTML 通知未生成统一样式或重点区域:%s", payload.HTML) } } func TestBuildMailContentHonorsOptionalFields(t *testing.T) { cfg := newTestConfig("http://mail.example.test") cfg.IncludeDeploymentDetails = false cfg.IncludeCertificateSerial = false cfg.IncludeManagementURL = false service := New(cfg, nil) payload, err := service.buildPayload(Event{ Type: "certificate_issue", Status: "success", Domain: "example.com", CertificateSerial: "serial-hidden", ManagementURL: "https://scert.example.test/hidden", Deployment: deployment.Report{ Total: 1, Success: 1, Status: "success", Results: []deployment.Result{{Server: "测试服务器", Status: "success", Message: "部署完成"}}, }, }) if err != nil { t.Fatalf("构造成功通知失败:%v", err) } if !strings.Contains(payload.Text, "部署概况:") || strings.Contains(payload.Text, "测试服务器") { t.Fatalf("部署概况与明细开关处理错误:%s", payload.Text) } for _, hidden := range []string{"serial-hidden", "https://scert.example.test/hidden", "测试服务器"} { if strings.Contains(payload.Text, hidden) || strings.Contains(payload.HTML, hidden) { t.Fatalf("关闭可选字段后仍包含 %q", hidden) } } } func TestBuildHTMLMailEscapesDynamicValues(t *testing.T) { cfg := newTestConfig("http://mail.example.test") service := New(cfg, nil) payload, err := service.buildPayload(Event{ Status: "failed", Domain: "", Error: "错误 ", }) if err != nil { t.Fatalf("构造转义通知失败:%v", err) } if strings.Contains(payload.HTML, "