package httpapi import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "golang.org/x/crypto/bcrypt" "git.pcux.cn/xuans/Scert/backend/internal/certificate" "git.pcux.cn/xuans/Scert/backend/internal/config" ) func TestSettingsAPIRequiresAuthentication(t *testing.T) { cfg := config.Default(t.TempDir()) cfg.SetupReady = true server := New(Dependencies{Config: cfg}) response := httptest.NewRecorder() server.Handler().ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil)) if response.Code != http.StatusUnauthorized { t.Fatalf("未登录访问设置接口应返回 401,实际为 %d:%s", response.Code, response.Body.String()) } } func TestSettingsAPIUpdatesSafeFieldsWithoutReturningSecrets(t *testing.T) { dir := t.TempDir() cfg := config.Default(dir) cfg.SetupReady = true cfg.DNS.VerificationState = "verified" cfg.Web.Hostname = "scert.example.test" cfg.Admin.Username = "admin" cfg.Admin.Email = "admin@example.test" cfg.Admin.PasswordHash = mustTestPasswordHash(t, "Password123") cfg.Database.DSN = "mysql://secret-user:secret-password@localhost/scert" configPath := dir + "\\config\\scert.json" if err := cfg.Save(configPath); err != nil { t.Fatalf("保存测试配置失败:%v", err) } server := New(Dependencies{Config: cfg, ConfigPath: configPath}) cookie := loginForSettingsTest(t, server) request := httptest.NewRequest(http.MethodPut, "/api/v1/settings", strings.NewReader(`{ "server": {"port": 5443}, "web": {"hostname": "admin.example.test"}, "acme": {"renewal_threshold_days": 10} }`)) request.Header.Set("Content-Type", "application/json") request.AddCookie(cookie) response := httptest.NewRecorder() server.Handler().ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("更新设置应成功,实际为 %d:%s", response.Code, response.Body.String()) } body := response.Body.String() for _, secret := range []string{"password_hash", "secret-password", "credential_ref", "tls_key", "mysql://"} { if strings.Contains(body, secret) { t.Fatalf("响应不应包含敏感字段 %q:%s", secret, body) } } var payload struct { Settings settingsView `json:"settings"` RestartRequired bool `json:"restart_required"` Message string `json:"message"` } if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil { t.Fatalf("解析设置响应失败:%v", err) } if !payload.RestartRequired || payload.Settings.Server.Port != 5443 || payload.Settings.Web.Hostname != "admin.example.test" || payload.Settings.ACME.RenewalThresholdDays != 10 { t.Fatalf("设置响应内容不正确:%+v", payload.Settings) } if !strings.Contains(payload.Message, "请重启 Scert 服务") { t.Fatalf("监听参数变更后应明确提示重启服务:%s", payload.Message) } saved, err := config.Load(configPath) if err != nil { t.Fatalf("读取保存后的配置失败:%v", err) } if saved.Admin.PasswordHash != cfg.Admin.PasswordHash || saved.Database.DSN != cfg.Database.DSN { t.Fatal("更新设置不应覆盖管理员密码或数据库 DSN") } } func TestSettingsAPIWebAddressChangeDoesNotRequireRestart(t *testing.T) { dir := t.TempDir() cfg := config.Default(dir) cfg.SetupReady = true cfg.DNS.VerificationState = "verified" cfg.Web.Hostname = "scert.example.test" cfg.Admin.Username = "admin" cfg.Admin.Email = "admin@example.test" cfg.Admin.PasswordHash = mustTestPasswordHash(t, "Password123") configPath := dir + "\\config\\scert.json" if err := cfg.Save(configPath); err != nil { t.Fatalf("保存测试配置失败:%v", err) } server := New(Dependencies{Config: cfg, ConfigPath: configPath}) cookie := loginForSettingsTest(t, server) request := httptest.NewRequest(http.MethodPut, "/api/v1/settings", strings.NewReader(`{ "web": {"hostname": "admin.example.test", "public_ip": "203.0.113.10"} }`)) request.Header.Set("Content-Type", "application/json") request.AddCookie(cookie) response := httptest.NewRecorder() server.Handler().ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("更新 Web 访问地址应成功,实际为 %d:%s", response.Code, response.Body.String()) } var payload struct { RestartRequired bool `json:"restart_required"` Message string `json:"message"` } if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil { t.Fatalf("解析设置响应失败:%v", err) } if payload.RestartRequired { t.Fatal("仅修改 Web 域名或公网 IP 不应要求重启服务") } if payload.Message != "设置已保存" { t.Fatalf("无需重启时应返回简洁保存提示:%s", payload.Message) } } func TestSettingsAPIReturnsChineseNotificationCredentialError(t *testing.T) { dir := t.TempDir() cfg := config.Default(dir) cfg.SetupReady = true cfg.DNS.VerificationState = "verified" cfg.Web.Hostname = "scert.example.test" cfg.Admin.Username = "admin" cfg.Admin.Email = "admin@example.test" cfg.Admin.PasswordHash = mustTestPasswordHash(t, "Password123") configPath := dir + "\\config\\scert.json" if err := cfg.Save(configPath); err != nil { t.Fatalf("保存测试配置失败:%v", err) } server := New(Dependencies{Config: cfg, ConfigPath: configPath}) cookie := loginForSettingsTest(t, server) request := httptest.NewRequest(http.MethodPut, "/api/v1/settings", strings.NewReader(`{ "notification": {"smtp_password": "secret-password"} }`)) request.Header.Set("Content-Type", "application/json") request.AddCookie(cookie) response := httptest.NewRecorder() server.Handler().ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("敏感凭据存储未配置时应返回 400,实际为 %d:%s", response.Code, response.Body.String()) } if !strings.Contains(response.Body.String(), "保存通知凭据失败") { t.Fatalf("响应应包含中文通知凭据错误说明:%s", response.Body.String()) } if strings.Contains(response.Body.String(), "???") { t.Fatalf("响应不应包含问号乱码:%s", response.Body.String()) } } func TestRenewalAPIUsesConfiguredRunner(t *testing.T) { cfg := config.Default(t.TempDir()) cfg.SetupReady = true cfg.DNS.VerificationState = "verified" cfg.Web.Hostname = "scert.example.test" cfg.Admin.Username = "admin" cfg.Admin.Email = "admin@example.test" cfg.Admin.PasswordHash = mustTestPasswordHash(t, "Password123") called := false server := New(Dependencies{ Config: cfg, RunRenewal: func(context.Context) (certificate.RenewalReport, error) { called = true return certificate.RenewalReport{Checked: 2, Selected: 1, Succeeded: 1}, nil }, }) cookie := loginForSettingsTest(t, server) request := httptest.NewRequest(http.MethodPost, "/api/v1/renewal/run", nil) request.AddCookie(cookie) response := httptest.NewRecorder() server.Handler().ServeHTTP(response, request) if response.Code != http.StatusOK || !called || !strings.Contains(response.Body.String(), `"selected":1`) { t.Fatalf("续期接口调用结果不正确:%d %s;called=%v", response.Code, response.Body.String(), called) } } func loginForSettingsTest(t *testing.T, server *Server) *http.Cookie { t.Helper() request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"identifier":"admin","password":"Password123"}`)) request.Header.Set("Content-Type", "application/json") response := httptest.NewRecorder() server.Handler().ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("管理员登录失败:%d %s", response.Code, response.Body.String()) } cookies := response.Result().Cookies() if len(cookies) == 0 { t.Fatal("登录响应未返回会话 Cookie") } return cookies[0] } func mustTestPasswordHash(t *testing.T, password string) string { t.Helper() hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost) if err != nil { t.Fatalf("生成测试密码摘要失败:%v", err) } return string(hash) } func TestSettingsAPIRollsBackCredentialsWhenValidationFails(t *testing.T) { dir := t.TempDir() cfg := config.Default(dir) cfg.SetupReady = true cfg.DNS.VerificationState = "verified" cfg.Web.Hostname = "scert.example.test" cfg.Admin.Username = "admin" cfg.Admin.Email = "admin@example.test" cfg.Admin.PasswordHash = mustTestPasswordHash(t, "Password123") configPath := dir + "\\config\\scert.json" if err := cfg.Save(configPath); err != nil { t.Fatalf("save test config: %v", err) } secrets := &fakeSetupSecretWriter{} server := New(Dependencies{Config: cfg, ConfigPath: configPath, Secrets: secrets}) cookie := loginForSettingsTest(t, server) request := httptest.NewRequest(http.MethodPut, "/api/v1/settings", strings.NewReader(`{ "notification": { "http_auth_mode": "bearer", "http_credentials": {"token": "new-token"}, "http_path": "email/send" } }`)) request.Header.Set("Content-Type", "application/json") request.AddCookie(cookie) response := httptest.NewRecorder() server.Handler().ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("invalid notification settings should fail: %d %s", response.Code, response.Body.String()) } if len(secrets.values) != 0 || len(secrets.deleted) != 1 { t.Fatalf("new credentials should be rolled back after validation failure: values=%v deleted=%v", secrets.values, secrets.deleted) } } func TestSettingsAPICleansReplacedNotificationCredential(t *testing.T) { dir := t.TempDir() cfg := config.Default(dir) cfg.SetupReady = true cfg.DNS.VerificationState = "verified" cfg.Web.Hostname = "scert.example.test" cfg.Admin.Username = "admin" cfg.Admin.Email = "admin@example.test" cfg.Admin.PasswordHash = mustTestPasswordHash(t, "Password123") cfg.Notification.HTTPAuthMode = "bearer" cfg.Notification.HTTPCredentialRef = "secret://notification/old" configPath := dir + "\\config\\scert.json" if err := cfg.Save(configPath); err != nil { t.Fatalf("save test config: %v", err) } secrets := &fakeSetupSecretWriter{values: map[string]map[string]string{ "secret://notification/old": {"token": "old-token"}, }} server := New(Dependencies{Config: cfg, ConfigPath: configPath, Secrets: secrets}) cookie := loginForSettingsTest(t, server) request := httptest.NewRequest(http.MethodPut, "/api/v1/settings", strings.NewReader(`{ "notification": { "http_auth_mode": "bearer", "http_credentials": {"token": "new-token"} } }`)) request.Header.Set("Content-Type", "application/json") request.AddCookie(cookie) response := httptest.NewRecorder() server.Handler().ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("notification credential replacement should succeed: %d %s", response.Code, response.Body.String()) } if _, ok := secrets.values["secret://notification/old"]; ok { t.Fatal("replaced notification credential should be deleted") } if len(secrets.values) != 1 { t.Fatalf("new notification credential should remain: %v", secrets.values) } if strings.Contains(response.Body.String(), "new-token") { t.Fatal("notification credential must not be returned in the response") } } func TestSettingsAPIRollsBackEarlierCredentialWhenLaterCredentialFails(t *testing.T) { dir := t.TempDir() cfg := config.Default(dir) cfg.SetupReady = true cfg.DNS.VerificationState = "verified" cfg.Web.Hostname = "scert.example.test" cfg.Admin.Username = "admin" cfg.Admin.Email = "admin@example.test" cfg.Admin.PasswordHash = mustTestPasswordHash(t, "Password123") cfg.Notification.SMTPUsername = "smtp-user" configPath := dir + "\\config\\scert.json" if err := cfg.Save(configPath); err != nil { t.Fatalf("save test config: %v", err) } secrets := &fakeSetupSecretWriter{} server := New(Dependencies{Config: cfg, ConfigPath: configPath, Secrets: secrets}) cookie := loginForSettingsTest(t, server) request := httptest.NewRequest(http.MethodPut, "/api/v1/settings", strings.NewReader(`{ "notification": { "http_auth_mode": "bearer", "http_credentials": {"token": "new-token"}, "smtp_enabled": true, "smtp_username": "", "smtp_password": "smtp-password" } }`)) request.Header.Set("Content-Type", "application/json") request.AddCookie(cookie) response := httptest.NewRecorder() server.Handler().ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("later SMTP validation should fail: %d %s", response.Code, response.Body.String()) } if len(secrets.values) != 0 || len(secrets.deleted) != 1 { t.Fatalf("earlier HTTP credentials should be rolled back: values=%v deleted=%v", secrets.values, secrets.deleted) } }