452 lines
17 KiB
Go
452 lines
17 KiB
Go
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: "<script>alert(1)</script>",
|
||
Error: "错误 <script>alert(2)</script>",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("构造转义通知失败:%v", err)
|
||
}
|
||
if strings.Contains(payload.HTML, "<script>") || !strings.Contains(payload.HTML, "<script>") {
|
||
t.Fatalf("HTML 动态内容未正确转义:%s", payload.HTML)
|
||
}
|
||
}
|