diff --git a/backend/internal/bootstrap/complete.go b/backend/internal/bootstrap/complete.go index 304d928..0140793 100644 --- a/backend/internal/bootstrap/complete.go +++ b/backend/internal/bootstrap/complete.go @@ -60,19 +60,20 @@ func (err *CompleteError) Unwrap() error { } type CompleteRequest struct { - DatabaseVerificationToken string `json:"database_verification_token"` - DNSVerificationToken string `json:"dns_verification_token"` - AdminUsername string `json:"admin_username"` - AdminEmail string `json:"admin_email"` - AdminPassword string `json:"admin_password"` - ServerPort int `json:"server_port"` - WebHostname string `json:"web_hostname"` - WebPublicIP string `json:"web_public_ip"` - WebEnabled bool `json:"web_enabled"` - HTTPSMode string `json:"https_mode"` - ACMEEnvironment string `json:"acme_environment"` - PropagationTimeoutSecs int `json:"propagation_timeout_secs"` - PropagationPollingIntervalSecs int `json:"propagation_polling_interval_secs"` + DatabaseVerificationToken string `json:"database_verification_token"` + DNSVerificationToken string `json:"dns_verification_token"` + AdminUsername string `json:"admin_username"` + AdminEmail string `json:"admin_email"` + AdminPassword string `json:"admin_password"` + ServerPort int `json:"server_port"` + WebHostname string `json:"web_hostname"` + WebPublicIP string `json:"web_public_ip"` + WebEnabled bool `json:"web_enabled"` + HTTPSMode string `json:"https_mode"` + ACMEEnvironment string `json:"acme_environment"` + PropagationTimeoutSecs int `json:"propagation_timeout_secs"` + PropagationPollingIntervalSecs int `json:"propagation_polling_interval_secs"` + Notification *NotificationInput `json:"notification,omitempty"` } type CompleteResult struct { @@ -245,7 +246,17 @@ func (completer *Completer) Complete(ctx context.Context, request CompleteReques candidate := completer.base candidate.Normalize() candidate.ACME.Environment = request.ACMEEnvironment - if err := setup.Apply(&candidate, setup.Input{ + notification, discardNotification, err := completer.prepareNotification(ctx, candidate.Notification, request.Notification) + defer func() { + if !committed && discardNotification != nil { + completer.cleanupCredential(discardNotification, "失败初始化的通知临时凭据回收未确认") + } + }() + if err != nil { + return result, err + } + candidate.Notification = notification + input := setup.Input{ AdminUsername: request.AdminUsername, AdminEmail: request.AdminEmail, AdminPassword: request.AdminPassword, ServerPort: request.ServerPort, DNSProvider: dnsSession.ProviderID, DNSCredentialRef: dnsSession.CredentialRef, DNSVerified: true, @@ -254,7 +265,9 @@ func (completer *Completer) Complete(ctx context.Context, request CompleteReques CertificateRoot: candidate.Paths.Certificate, WebHostname: request.WebHostname, WebPublicIP: request.WebPublicIP, WebEnabled: request.WebEnabled, HTTPSMode: request.HTTPSMode, - }); err != nil { + } + applyNotificationInput(&input, notification) + if err := setup.Apply(&candidate, input); err != nil { return result, completeError(CompleteErrorInput, "初始化参数校验失败", err) } candidate.Database = databaseSession.Config @@ -489,11 +502,8 @@ func managementURL(candidate config.Config) string { if ip := net.ParseIP(strings.Trim(host, "[]")); ip != nil && ip.To4() == nil { host = "[" + ip.String() + "]" } - if candidate.Server.Port != 443 { + if candidate.Server.HTTPSMode != "proxy" && candidate.Server.Port != 443 { host = net.JoinHostPort(strings.Trim(host, "[]"), strconv.Itoa(candidate.Server.Port)) - if strings.HasPrefix(host, "[") && candidate.Server.Port == 443 { - host = strings.TrimSuffix(strings.TrimPrefix(host, "["), "]") - } } return (&url.URL{Scheme: "https", Host: host, Path: "/"}).String() } diff --git a/backend/internal/bootstrap/notification.go b/backend/internal/bootstrap/notification.go new file mode 100644 index 0000000..0f688b6 --- /dev/null +++ b/backend/internal/bootstrap/notification.go @@ -0,0 +1,202 @@ +package bootstrap + +import ( + "context" + "errors" + "strings" + "sync" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/config" + "git.pcux.cn/xuans/Scert/backend/internal/setup" +) + +// NotificationInput 不接受客户端指定凭据引用;新密码只写入加密存储。 +// 省略整个通知对象时保留原配置,提交对象时按完整表单应用配置。 +type NotificationInput struct { + HTTPEnabled bool `json:"http_enabled"` + SMTPEnabled bool `json:"smtp_enabled"` + Provider string `json:"provider"` + Endpoint string `json:"endpoint"` + HTTPPath string `json:"http_path"` + HTTPLoginPath string `json:"http_login_path"` + HTTPAuthMode string `json:"http_auth_mode"` + HTTPCredentials map[string]string `json:"http_credentials"` + HTTPHeaders map[string]string `json:"http_headers"` + EventMode string `json:"event_mode"` + IncludeDeploymentDetails bool `json:"include_deployment_details"` + IncludeCertificateSerial bool `json:"include_certificate_serial"` + IncludeManagementURL bool `json:"include_management_url"` + SenderName string `json:"sender_name"` + SenderEmail string `json:"sender_email"` + Recipients []string `json:"recipients"` + TimeoutSecs int `json:"timeout_secs"` + RetryCount int `json:"retry_count"` + RetryIntervalsSecs []int `json:"retry_intervals_secs"` + SMTPHost string `json:"smtp_host"` + SMTPPort int `json:"smtp_port"` + SMTPUsername string `json:"smtp_username"` + SMTPPassword string `json:"smtp_password"` + SMTPMode string `json:"smtp_mode"` + SMTPAllowPlaintextAuth bool `json:"smtp_allow_plaintext_auth"` +} + +func (completer *Completer) prepareNotification(ctx context.Context, current config.NotificationConfig, input *NotificationInput) (config.NotificationConfig, func() error, error) { + if input == nil { + return current, nil, nil + } + candidate := config.NotificationConfig{ + HTTPEnabled: input.HTTPEnabled, SMTPEnabled: input.SMTPEnabled, + Provider: strings.TrimSpace(input.Provider), Endpoint: strings.TrimSpace(input.Endpoint), + HTTPPath: strings.TrimSpace(input.HTTPPath), HTTPLoginPath: strings.TrimSpace(input.HTTPLoginPath), + HTTPAuthMode: strings.ToLower(strings.TrimSpace(input.HTTPAuthMode)), HTTPHeaders: input.HTTPHeaders, + EventMode: strings.TrimSpace(input.EventMode), IncludeDeploymentDetails: input.IncludeDeploymentDetails, + IncludeCertificateSerial: input.IncludeCertificateSerial, IncludeManagementURL: input.IncludeManagementURL, + SenderName: strings.TrimSpace(input.SenderName), SenderEmail: strings.TrimSpace(input.SenderEmail), + Recipients: input.Recipients, TimeoutSecs: input.TimeoutSecs, + RetryCount: input.RetryCount, RetryIntervalsSecs: input.RetryIntervalsSecs, + SMTPHost: strings.TrimSpace(input.SMTPHost), SMTPPort: input.SMTPPort, + SMTPUsername: strings.TrimSpace(input.SMTPUsername), SMTPMode: strings.ToLower(strings.TrimSpace(input.SMTPMode)), + SMTPAllowPlaintextAuth: input.SMTPAllowPlaintextAuth, + } + if candidate.HTTPAuthMode == "" || !candidate.HTTPEnabled { + candidate.HTTPAuthMode = "none" + } + if candidate.HTTPPath == "" { + candidate.HTTPPath = "/api/email/send" + } + if candidate.HTTPLoginPath == "" { + candidate.HTTPLoginPath = "/api/login" + } + if candidate.EventMode == "" { + candidate.EventMode = "detailed" + } + if candidate.SenderName == "" { + candidate.SenderName = "Scert" + } + if candidate.TimeoutSecs == 0 { + candidate.TimeoutSecs = 10 + } + if candidate.SMTPPort == 0 { + candidate.SMTPPort = 587 + } + if candidate.SMTPMode == "" { + candidate.SMTPMode = "starttls" + } + if candidate.RetryCount < 0 || len(candidate.RetryIntervalsSecs) != candidate.RetryCount || candidate.TimeoutSecs < 1 { + return candidate, nil, completeError(CompleteErrorInput, "通知超时或重试参数无效", nil) + } + if candidate.RetryCount == 0 { + candidate.RetryIntervalsSecs = []int{} + } + if candidate.HTTPEnabled && candidate.Endpoint == "" { + return candidate, nil, completeError(CompleteErrorInput, "启用 HTTP 通知时必须配置服务地址", nil) + } + if candidate.SMTPEnabled && (candidate.SMTPHost == "" || candidate.SMTPUsername == "" || input.SMTPPassword == "" || + (candidate.SMTPMode == "plain" && !candidate.SMTPAllowPlaintextAuth)) { + return candidate, nil, completeError(CompleteErrorInput, "SMTP 通知主机、账号、密码或连接安全参数无效", nil) + } + credentials := make(map[string]map[string]string) + if candidate.HTTPEnabled && candidate.HTTPAuthMode != "none" { + values, err := notificationCredentials(candidate.HTTPAuthMode, input.HTTPCredentials) + if err != nil { + return candidate, nil, completeError(CompleteErrorInput, "HTTP 通知认证凭据无效", err) + } + credentials["http"] = values + } + if candidate.SMTPEnabled { + credentials["smtp"] = map[string]string{"username": candidate.SMTPUsername, "password": input.SMTPPassword} + } + if len(credentials) == 0 { + return candidate, nil, nil + } + store, ok := completer.credentials.(DatabaseSecretStore) + if !ok { + return candidate, nil, completeError(CompleteErrorInternal, "通知加密凭据存储尚未就绪", nil) + } + references := make([]string, 0, len(credentials)) + cleanup := sync.OnceValue(func() error { + var failures []error + for _, reference := range references { + cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + failures = append(failures, store.Delete(cleanupCtx, reference)) + cancel() + } + return errors.Join(failures...) + }) + for _, kind := range []string{"http", "smtp"} { + values, exists := credentials[kind] + if !exists { + continue + } + token, err := randomToken(24) + if err != nil { + return candidate, cleanup, completeError(CompleteErrorInternal, "生成通知凭据引用失败", err) + } + reference := "secret://notification/" + token + references = append(references, reference) + if err := store.Put(ctx, reference, values); err != nil { + return candidate, cleanup, completeError(CompleteErrorDependency, "加密保存通知凭据失败", err) + } + if kind == "http" { + candidate.HTTPCredentialRef = reference + } else { + candidate.SMTPCredentialRef = reference + } + } + return candidate, cleanup, nil +} + +func notificationCredentials(mode string, input map[string]string) (map[string]string, error) { + first := func(keys ...string) string { + for _, key := range keys { + if value := input[key]; strings.TrimSpace(value) != "" { + return value + } + } + return "" + } + switch mode { + case "bearer": + if value := first("token", "access_token"); value != "" { + return map[string]string{"token": value}, nil + } + case "hmac_sha256": + if value := first("secret", "hmac_secret", "key", "token"); value != "" { + return map[string]string{"secret": value}, nil + } + case "session": + if account := first("account", "username", "identifier"); account != "" && input["password"] != "" { + return map[string]string{"account": account, "password": input["password"]}, nil + } + } + return nil, errors.New("通知认证方式或必填凭据无效") +} + +func applyNotificationInput(input *setup.Input, notification config.NotificationConfig) { + input.EmailHTTPEnabled = notification.HTTPEnabled + input.EmailSMTPEnabled = notification.SMTPEnabled + input.EmailSMTPHost = notification.SMTPHost + input.EmailSMTPPort = notification.SMTPPort + input.EmailSMTPUsername = notification.SMTPUsername + input.EmailSMTPCredentialRef = notification.SMTPCredentialRef + input.EmailSMTPMode = notification.SMTPMode + input.EmailSMTPAllowPlaintextAuth = notification.SMTPAllowPlaintextAuth + input.EmailProvider = notification.Provider + input.EmailEndpoint = notification.Endpoint + input.EmailHTTPPath = notification.HTTPPath + input.EmailHTTPLoginPath = notification.HTTPLoginPath + input.EmailHTTPAuthMode = notification.HTTPAuthMode + input.EmailHTTPCredentialRef = notification.HTTPCredentialRef + input.EmailHTTPHeaders = notification.HTTPHeaders + input.EmailEventMode = notification.EventMode + input.EmailIncludeDeploymentDetails = notification.IncludeDeploymentDetails + input.EmailIncludeCertificateSerial = notification.IncludeCertificateSerial + input.EmailIncludeManagementURL = notification.IncludeManagementURL + input.EmailSenderName = notification.SenderName + input.EmailSenderEmail = notification.SenderEmail + input.EmailRecipients = notification.Recipients + input.EmailTimeoutSecs = notification.TimeoutSecs + input.EmailRetryCount = notification.RetryCount + input.EmailRetryIntervalsSecs = notification.RetryIntervalsSecs +} diff --git a/backend/internal/bootstrap/notification_test.go b/backend/internal/bootstrap/notification_test.go new file mode 100644 index 0000000..8d2ebe6 --- /dev/null +++ b/backend/internal/bootstrap/notification_test.go @@ -0,0 +1,197 @@ +package bootstrap + +import ( + "context" + "errors" + "os" + "reflect" + "strings" + "testing" + + "git.pcux.cn/xuans/Scert/backend/internal/config" +) + +func completionNotificationInput() *NotificationInput { + return &NotificationInput{ + HTTPEnabled: true, Endpoint: "https://mail.example.test", HTTPAuthMode: "session", + HTTPCredentials: map[string]string{"account": "mail-owner", "password": "private-http-password"}, + SMTPEnabled: true, SMTPHost: "smtp.example.test", SMTPUsername: "smtp-owner", SMTPPassword: "private-smtp-password", + IncludeManagementURL: true, IncludeCertificateSerial: true, + Recipients: []string{"notify@example.test"}, TimeoutSecs: 15, RetryCount: 0, RetryIntervalsSecs: []int{}, + } +} + +func TestCompleterPreservesExistingNotificationWhenOmitted(t *testing.T) { + fixture := newCompletionFixture(t, "admin.example.test") + fixture.base.Notification.HTTPEnabled = true + fixture.base.Notification.Endpoint = "https://mail.example.test" + fixture.base.Notification.HTTPAuthMode = "bearer" + fixture.base.Notification.HTTPCredentialRef = "secret://notification/existing" + fixture.base.Notification.SenderEmail = "sender@example.test" + fixture.base.Notification.Recipients = []string{"notify@example.test"} + fixture.base.Notification.HTTPHeaders = map[string]string{"X-Client": "Scert"} + fixture.base.Notification.IncludeDeploymentDetails = true + if _, err := fixture.completer(nil).Complete(t.Context(), fixture.request("admin.example.test")); err != nil { + t.Fatal(err) + } + stored, err := config.Load(fixture.configPath) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(stored.Notification, fixture.base.Notification) { + t.Fatalf("省略通知表单时原有通知设置发生丢失:%+v", stored.Notification) + } +} + +func TestCompleterStoresNotificationCredentialsWithoutPlaintext(t *testing.T) { + fixture := newCompletionFixture(t, "admin.example.test") + request := fixture.request("admin.example.test") + request.Notification = completionNotificationInput() + if _, err := fixture.completer(nil).Complete(t.Context(), request); err != nil { + t.Fatal(err) + } + stored, err := config.Load(fixture.configPath) + if err != nil { + t.Fatal(err) + } + if !stored.Notification.HTTPEnabled || !stored.Notification.SMTPEnabled || stored.Notification.RetryCount != 0 || + len(stored.Notification.RetryIntervalsSecs) != 0 || !stored.Notification.IncludeCertificateSerial { + t.Fatalf("通知选项未完整保存:%+v", stored.Notification) + } + httpCredentials, err := fixture.store.Get(t.Context(), stored.Notification.HTTPCredentialRef) + if err != nil || httpCredentials["password"] != "private-http-password" { + t.Fatalf("HTTP 通知凭据未进入敏感存储:%v", err) + } + smtpCredentials, err := fixture.store.Get(t.Context(), stored.Notification.SMTPCredentialRef) + if err != nil || smtpCredentials["password"] != "private-smtp-password" { + t.Fatalf("SMTP 通知凭据未进入敏感存储:%v", err) + } + data, err := os.ReadFile(fixture.configPath) + if err != nil { + t.Fatal(err) + } + for _, secret := range []string{"private-http-password", "private-smtp-password"} { + if strings.Contains(string(data), secret) { + t.Fatal("正式配置包含通知明文密码") + } + } +} + +func TestCompleterReclaimsOnlyNewNotificationCredentialsOnFailure(t *testing.T) { + fixture := newCompletionFixture(t, "admin.example.test") + existing := "secret://notification/existing" + if err := fixture.store.Put(t.Context(), existing, map[string]string{"token": "existing-token"}); err != nil { + t.Fatal(err) + } + request := fixture.request("admin.example.test") + request.Notification = completionNotificationInput() + _, err := fixture.completer(func(dependencies *CompleteDependencies) { + dependencies.SaveConfig = func(config.Config, string) error { return errors.New("save failed") } + }).Complete(t.Context(), request) + if err == nil { + t.Fatal("保存失败未返回错误") + } + fixture.store.mu.Lock() + defer fixture.store.mu.Unlock() + if len(fixture.store.values) != 1 || fixture.store.values[existing]["token"] != "existing-token" { + t.Fatalf("回滚必须保留已有凭据并回收新凭据:剩余=%d", len(fixture.store.values)) + } +} + +type failingNotificationStore struct { + *completionMemorySecrets + puts int +} + +func (store *failingNotificationStore) Put(ctx context.Context, reference string, values map[string]string) error { + store.puts++ + if err := store.completionMemorySecrets.Put(ctx, reference, values); err != nil { + return err + } + if store.puts == 2 { + return errors.New("partial write failure") + } + return nil +} + +func TestNotificationPartialWriteFailureReclaimsAllCreatedReferences(t *testing.T) { + fixture := newCompletionFixture(t, "admin.example.test") + store := &failingNotificationStore{completionMemorySecrets: fixture.store} + request := fixture.request("admin.example.test") + request.Notification = completionNotificationInput() + _, err := fixture.completer(func(dependencies *CompleteDependencies) { + dependencies.Credentials = store + }).Complete(t.Context(), request) + if err == nil || store.puts != 2 || fixture.issuer.calls != 0 { + t.Fatalf("部分写入失败未中止初始化:%v", err) + } + fixture.store.mu.Lock() + defer fixture.store.mu.Unlock() + if len(fixture.store.values) != 0 { + t.Fatal("部分写入失败遗留通知或 DNS 临时凭据") + } +} + +func TestNotificationAuthenticationModes(t *testing.T) { + for _, item := range []struct { + mode string + input map[string]string + key, value string + }{ + {"bearer", map[string]string{"access_token": "test-token"}, "token", "test-token"}, + {"hmac_sha256", map[string]string{"hmac_secret": "test-key"}, "secret", "test-key"}, + {"session", map[string]string{"username": "owner", "password": "test-password"}, "account", "owner"}, + } { + t.Run(item.mode, func(t *testing.T) { + values, err := notificationCredentials(item.mode, item.input) + if err != nil || values[item.key] != item.value { + t.Fatalf("通知凭据兼容字段转换不正确:%v", err) + } + if _, err := notificationCredentials(item.mode, nil); err == nil { + t.Fatal("缺少通知认证凭据未拒绝") + } + }) + } +} + +func TestNotificationRejectsInvalidSettingsBeforeWritingSecrets(t *testing.T) { + for _, edit := range []func(*NotificationInput){ + func(input *NotificationInput) { input.Endpoint = "" }, + func(input *NotificationInput) { input.RetryCount = -1 }, + func(input *NotificationInput) { input.SMTPPassword = "" }, + func(input *NotificationInput) { input.SMTPMode = "plain" }, + func(input *NotificationInput) { input.HTTPCredentials = nil }, + } { + fixture := newCompletionFixture(t, "admin.example.test") + input := completionNotificationInput() + edit(input) + _, cleanup, err := fixture.completer(nil).prepareNotification(t.Context(), fixture.base.Notification, input) + if err == nil || cleanup != nil { + t.Fatal("无效通知配置不得写入敏感存储") + } + } +} + +func TestManagementURLHandlesDirectAndProxyEntrypoints(t *testing.T) { + for _, item := range []struct { + hostname, ip, mode string + port int + want string + }{ + {"admin.example.test", "", "direct", 443, "https://admin.example.test/"}, + {"admin.example.test", "", "direct", 8443, "https://admin.example.test:8443/"}, + {"admin.example.test", "", "proxy", 8080, "https://admin.example.test/"}, + {"", "203.0.113.70", "direct", 8443, "https://203.0.113.70:8443/"}, + {"", "2001:db8::70", "direct", 443, "https://[2001:db8::70]/"}, + {"", "2001:db8::70", "direct", 8443, "https://[2001:db8::70]:8443/"}, + {"", "2001:db8::70", "proxy", 8080, "https://[2001:db8::70]/"}, + } { + candidate := config.Config{ + Web: config.WebConfig{Hostname: item.hostname, PublicIP: item.ip}, + Server: config.ServerConfig{Port: item.port, HTTPSMode: item.mode}, + } + if actual := managementURL(candidate); actual != item.want { + t.Fatalf("管理入口不正确:实际=%s,预期=%s", actual, item.want) + } + } +} diff --git a/backend/internal/bootstrap/router_complete_test.go b/backend/internal/bootstrap/router_complete_test.go index b4769e2..d035228 100644 --- a/backend/internal/bootstrap/router_complete_test.go +++ b/backend/internal/bootstrap/router_complete_test.go @@ -62,6 +62,7 @@ func TestBootstrapCompleteRejectsInvalidBodiesBeforeSideEffects(t *testing.T) { name, body, contentType string }{ {"unknown", `{"extra":true}`, "application/json"}, + {"credential_reference", `{"notification":{"http_credential_ref":"secret://notification/other"}}`, "application/json"}, {"trailing", `{} {}`, "application/json"}, {"trailing_null", `{} null`, "application/json"}, {"null", `null`, "application/json"}, @@ -134,6 +135,7 @@ func TestBootstrapCompleteUnavailableAndInvalidResult(t *testing.T) { func TestBootstrapCompleteCommitsAndClearsCookie(t *testing.T) { fixture := newCompletionFixture(t, "admin.example.test") request := fixture.request("admin.example.test") + request.Notification = completionNotificationInput() body, err := json.Marshal(request) if err != nil { t.Fatal(err) @@ -149,7 +151,7 @@ func TestBootstrapCompleteCommitsAndClearsCookie(t *testing.T) { if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil || !result.Completed || result.ManagementURL != "https://admin.example.test:8443/" { t.Fatalf("管理入口响应不正确:%+v,%v", result, err) } - assertResponseExcludes(t, response, request.AdminPassword, request.DatabaseVerificationToken, request.DNSVerificationToken, "secret://", "test-secret") + assertResponseExcludes(t, response, request.AdminPassword, request.DatabaseVerificationToken, request.DNSVerificationToken, "secret://", "test-secret", "private-http-password", "private-smtp-password") cookies := response.Result().Cookies() if len(cookies) != 1 || cookies[0].Name != SessionCookieName || cookies[0].Value != "" || cookies[0].MaxAge != -1 || !cookies[0].HttpOnly || !cookies[0].Secure || cookies[0].Path != "/api/v1/bootstrap" || cookies[0].SameSite != http.SameSiteStrictMode { diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 611997f..e06f6f1 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -382,10 +382,11 @@ func (c *Config) Normalize() { if c.Notification.TimeoutSecs == 0 { c.Notification.TimeoutSecs = 10 } - if c.Notification.RetryCount == 0 { + // 空数组表示明确关闭重试,只有旧配置未提供间隔字段时才补默认值。 + if c.Notification.RetryCount == 0 && c.Notification.RetryIntervalsSecs == nil { c.Notification.RetryCount = 3 } - if len(c.Notification.RetryIntervalsSecs) == 0 { + if c.Notification.RetryIntervalsSecs == nil { c.Notification.RetryIntervalsSecs = []int{30, 120, 600} } if c.Notification.HTTPHeaders == nil { diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 871079a..f38aff8 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -347,3 +347,26 @@ func TestLoadWithEnvRejectsInvalidInteger(t *testing.T) { t.Fatal("无效端口环境变量未被拒绝") } } + +func TestSaveAndLoadPreservesDisabledNotificationRetries(t *testing.T) { + directory := t.TempDir() + cfg := Default(directory) + cfg.Notification.RetryCount = 0 + cfg.Notification.RetryIntervalsSecs = []int{} + path := filepath.Join(directory, "scert.json") + if err := cfg.Save(path); err != nil { + t.Fatal(err) + } + loaded, err := Load(path) + if err != nil { + t.Fatal(err) + } + if loaded.Notification.RetryCount != 0 || len(loaded.Notification.RetryIntervalsSecs) != 0 { + t.Fatal("显式关闭通知重试不得在重启时恢复默认重试次数") + } + cfg.Notification.RetryIntervalsSecs = nil + cfg.Normalize() + if cfg.Notification.RetryCount != 3 || len(cfg.Notification.RetryIntervalsSecs) != 3 { + t.Fatal("缺失通知重试配置的历史配置仍需补齐默认值") + } +} diff --git a/backend/internal/setup/setup.go b/backend/internal/setup/setup.go index 9872693..a0e3bb1 100644 --- a/backend/internal/setup/setup.go +++ b/backend/internal/setup/setup.go @@ -174,7 +174,7 @@ func Apply(cfg *config.Config, input Input) error { cfg.Notification.RetryCount = input.EmailRetryCount } if input.EmailRetryIntervalsSecs != nil { - cfg.Notification.RetryIntervalsSecs = append([]int(nil), input.EmailRetryIntervalsSecs...) + cfg.Notification.RetryIntervalsSecs = append([]int{}, input.EmailRetryIntervalsSecs...) } cfg.Notification.Recipients = cleanRecipients(input.EmailRecipients) if len(cfg.Notification.Recipients) == 0 {