Scert/backend/internal/httpapi/settings_api_test.go

696 lines
28 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package httpapi
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"golang.org/x/crypto/bcrypt"
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
"git.pcux.cn/xuans/Scert/backend/internal/config"
"git.pcux.cn/xuans/Scert/backend/internal/setup"
)
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 TestSettingsAPIReturnsAndUpdatesRenewalPolicy(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)
}
var applied certificate.RenewalPolicy
appliedCalled := false
server := New(Dependencies{
Config: cfg,
ConfigPath: configPath,
UpdateRenewalPolicy: func(policy certificate.RenewalPolicy) {
applied = policy
appliedCalled = true
},
})
cookie := loginForSettingsTest(t, server)
getRequest := httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil)
getRequest.AddCookie(cookie)
getResponse := httptest.NewRecorder()
server.Handler().ServeHTTP(getResponse, getRequest)
if getResponse.Code != http.StatusOK {
t.Fatalf("读取设置应成功,实际为 %d:%s", getResponse.Code, getResponse.Body.String())
}
var initial struct {
ACME settingsACMEView `json:"acme"`
}
if err := json.Unmarshal(getResponse.Body.Bytes(), &initial); err != nil {
t.Fatalf("解析初始设置失败:%v", err)
}
if initial.ACME.RenewalCheckIntervalDays != 3 || initial.ACME.RenewalNearExpiryDays != 10 || initial.ACME.RenewalThresholdDays != 6 || initial.ACME.RenewalUrgentThresholdDays != 3 || initial.ACME.RenewalMaxRetries != 3 || initial.ACME.RenewalRetryIntervalMins != 30 {
t.Fatalf("初始续期策略返回不完整:%+v", initial.ACME)
}
request := httptest.NewRequest(http.MethodPut, "/api/v1/settings", strings.NewReader(`{
"acme": {
"renewal_check_interval_days": 2,
"renewal_near_expiry_days": 14,
"renewal_threshold_days": 7,
"renewal_urgent_threshold_days": 2,
"renewal_max_retries": 5,
"renewal_retry_interval_mins": 45
}
}`))
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())
}
var payload struct {
Settings settingsView `json:"settings"`
Restart bool `json:"restart_required"`
}
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
t.Fatalf("解析续期策略响应失败:%v", err)
}
if payload.Restart || payload.Settings.ACME.RenewalCheckIntervalDays != 2 || payload.Settings.ACME.RenewalNearExpiryDays != 14 || payload.Settings.ACME.RenewalThresholdDays != 7 || payload.Settings.ACME.RenewalUrgentThresholdDays != 2 || payload.Settings.ACME.RenewalMaxRetries != 5 || payload.Settings.ACME.RenewalRetryIntervalMins != 45 {
t.Fatalf("更新后的续期策略不正确:restart=%v acme=%+v", payload.Restart, payload.Settings.ACME)
}
if !appliedCalled {
t.Fatal("\u4fdd\u5b58\u7eed\u671f\u7b56\u7565\u540e\u5e94\u7acb\u5373\u8c03\u7528\u8fd0\u884c\u65f6\u66f4\u65b0\u56de\u8c03")
}
if applied.CheckInterval != 2*24*time.Hour || applied.NearExpiryThreshold != 14*24*time.Hour || applied.RenewalThreshold != 7*24*time.Hour || applied.UrgentThreshold != 2*24*time.Hour || applied.MaxRetries != 5 || applied.RetryInterval != 45*time.Minute {
t.Fatalf("\u8fd0\u884c\u65f6\u7eed\u671f\u7b56\u7565\u672a\u6b63\u786e\u8f6c\u6362\uff1a%+v", applied)
}
saved, err := config.Load(configPath)
if err != nil {
t.Fatalf("读取续期策略配置失败:%v", err)
}
if saved.ACME.RenewalCheckIntervalDays != 2 || saved.ACME.RenewalNearExpiryDays != 14 || saved.ACME.RenewalThresholdDays != 7 || saved.ACME.RenewalUrgentThresholdDays != 2 || saved.ACME.RenewalMaxRetries != 5 || saved.ACME.RenewalRetryIntervalMins != 45 {
t.Fatalf("续期策略没有正确持久化:%+v", saved.ACME)
}
}
func TestSettingsAPIRejectsInvalidRenewalPolicy(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(`{
"acme": {
"renewal_near_expiry_days": 2,
"renewal_threshold_days": 7
}
}`))
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())
}
saved, err := config.Load(configPath)
if err != nil {
t.Fatalf("读取回滚后的配置失败:%v", err)
}
if saved.ACME.RenewalNearExpiryDays != cfg.ACME.RenewalNearExpiryDays || saved.ACME.RenewalThresholdDays != cfg.ACME.RenewalThresholdDays {
t.Fatalf("非法设置不应持久化:before=%+v after=%+v", cfg.ACME, saved.ACME)
}
}
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)
}
}
func TestSettingsAPIDNSRequiresCompleteVerification(t *testing.T) {
dir := t.TempDir()
cfg := config.Default(dir)
cfg.SetupReady = true
cfg.DNS.VerificationState = "verified"
cfg.DNS.Provider = "cloudflare"
cfg.DNS.CredentialRef = "secret://dns/cloudflare/old"
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,
SetupDNS: &fakeSetupDNSVerifier{},
})
cookie := loginForSettingsTest(t, server)
request := httptest.NewRequest(http.MethodPut, "/api/v1/settings", strings.NewReader(`{
"dns": {"provider": "cloudflare", "verification_token": "token-only"}
}`))
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("DNS 验证参数不完整应返回 400,实际为 %d:%s", response.Code, response.Body.String())
}
if !strings.Contains(response.Body.String(), "保存 DNS 设置失败") {
t.Fatalf("错误响应应说明 DNS 设置保存失败:%s", response.Body.String())
}
}
func TestSettingsAPIDNSProviderChangeRequiresReverification(t *testing.T) {
dir := t.TempDir()
cfg := config.Default(dir)
cfg.SetupReady = true
cfg.DNS.VerificationState = "verified"
cfg.DNS.Provider = "cloudflare"
cfg.DNS.CredentialRef = "secret://dns/cloudflare/old"
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(`{
"dns": {"provider": "aliyun"}
}`))
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("更换 DNS 服务商但未重新验证应返回 400,实际为 %d:%s", response.Code, response.Body.String())
}
if !strings.Contains(response.Body.String(), "设置内容校验失败") {
t.Fatalf("错误响应应说明设置校验失败:%s", response.Body.String())
}
}
func TestSettingsAPISavesVerifiedDNSCredentialWithoutReturningSecret(t *testing.T) {
dir := t.TempDir()
cfg := config.Default(dir)
cfg.SetupReady = true
cfg.DNS.VerificationState = "verified"
cfg.DNS.Provider = "cloudflare"
cfg.DNS.CredentialRef = "secret://dns/cloudflare/old"
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)
}
const newCredentialRef = "secret://dns/cloudflare/new"
verifier := &fakeSetupDNSVerifier{verifyResult: setup.DNSVerificationResult{
CredentialRef: newCredentialRef, VerificationToken: "new-verification-token",
}}
secrets := &fakeSetupSecretWriter{values: map[string]map[string]string{
cfg.DNS.CredentialRef: {"api_token": "old-token"},
newCredentialRef: {"api_token": "new-token"},
}}
server := New(Dependencies{Config: cfg, ConfigPath: configPath, SetupDNS: verifier, Secrets: secrets})
cookie := loginForSettingsTest(t, server)
request := httptest.NewRequest(http.MethodPut, "/api/v1/settings", strings.NewReader(`{
"dns": {
"provider": "cloudflare",
"credential_ref": "secret://dns/cloudflare/new",
"verification_token": "new-verification-token",
"test_domain": "example.com"
}
}`))
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("保存已验证 DNS 设置应成功,实际为 %d:%s", response.Code, response.Body.String())
}
body := response.Body.String()
for _, secret := range []string{"new-verification-token", newCredentialRef, "new-token", "credential_ref"} {
if strings.Contains(body, secret) {
t.Fatalf("设置响应不应返回 DNS 敏感内容 %q:%s", secret, body)
}
}
if !verifier.consumed {
t.Fatal("保存成功后应消费 DNS 验证令牌")
}
if _, ok := secrets.values[cfg.DNS.CredentialRef]; ok {
t.Fatal("替换后的旧 DNS 凭据应被清理")
}
if _, ok := secrets.values[newCredentialRef]; !ok {
t.Fatal("新的 DNS 凭据应继续保留")
}
saved, err := config.Load(configPath)
if err != nil {
t.Fatalf("读取保存后的配置失败:%v", err)
}
if saved.DNS.CredentialRef != newCredentialRef || saved.DNS.VerificationState != "verified" {
t.Fatalf("DNS 配置没有正确持久化:%+v", saved.DNS)
}
}
func TestSettingsAPIDNSCheckFailureRollsBackCredential(t *testing.T) {
dir := t.TempDir()
cfg := config.Default(dir)
cfg.SetupReady = true
cfg.DNS.VerificationState = "verified"
cfg.DNS.Provider = "cloudflare"
cfg.DNS.CredentialRef = "secret://dns/cloudflare/old"
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)
}
const newCredentialRef = "secret://dns/cloudflare/new"
verifier := &fakeSetupDNSVerifier{checkErr: errors.New("DNS 验证令牌无效")}
secrets := &fakeSetupSecretWriter{values: map[string]map[string]string{
newCredentialRef: {"api_token": "new-token"},
}}
server := New(Dependencies{Config: cfg, ConfigPath: configPath, SetupDNS: verifier, Secrets: secrets})
cookie := loginForSettingsTest(t, server)
request := httptest.NewRequest(http.MethodPut, "/api/v1/settings", strings.NewReader(`{
"dns": {
"provider": "cloudflare",
"credential_ref": "secret://dns/cloudflare/new",
"verification_token": "invalid-token",
"test_domain": "example.com"
}
}`))
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("无效 DNS 验证令牌应返回 400,实际为 %d:%s", response.Code, response.Body.String())
}
if _, ok := secrets.values[newCredentialRef]; ok {
t.Fatal("DNS 验证失败后不应保留新凭据")
}
if len(secrets.deleted) != 1 || secrets.deleted[0] != newCredentialRef {
t.Fatalf("DNS 验证失败后应删除新凭据:%v", secrets.deleted)
}
}
func TestSettingsAPIDNSSaveFailureRollsBackCredential(t *testing.T) {
dir := t.TempDir()
cfg := config.Default(dir)
cfg.SetupReady = true
cfg.DNS.VerificationState = "verified"
cfg.DNS.Provider = "cloudflare"
cfg.DNS.CredentialRef = "secret://dns/cloudflare/old"
cfg.Web.Hostname = "scert.example.test"
cfg.Admin.Username = "admin"
cfg.Admin.Email = "admin@example.test"
cfg.Admin.PasswordHash = mustTestPasswordHash(t, "Password123")
initialConfigPath := dir + "\\config\\initial.json"
if err := cfg.Save(initialConfigPath); err != nil {
t.Fatalf("保存测试配置失败:%v", err)
}
blockedPath := dir + "\\blocked"
if err := os.WriteFile(blockedPath, []byte("not a directory"), 0o600); err != nil {
t.Fatalf("创建保存失败测试文件失败:%v", err)
}
const newCredentialRef = "secret://dns/cloudflare/new"
verifier := &fakeSetupDNSVerifier{verifyResult: setup.DNSVerificationResult{
CredentialRef: newCredentialRef, VerificationToken: "new-verification-token",
}}
secrets := &fakeSetupSecretWriter{values: map[string]map[string]string{
newCredentialRef: {"api_token": "new-token"},
}}
server := New(Dependencies{Config: cfg, ConfigPath: blockedPath + "\\scert.json", SetupDNS: verifier, Secrets: secrets})
cookie := loginForSettingsTest(t, server)
request := httptest.NewRequest(http.MethodPut, "/api/v1/settings", strings.NewReader(`{
"dns": {
"provider": "cloudflare",
"credential_ref": "secret://dns/cloudflare/new",
"verification_token": "new-verification-token",
"test_domain": "example.com"
}
}`))
request.Header.Set("Content-Type", "application/json")
request.AddCookie(cookie)
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, request)
if response.Code != http.StatusInternalServerError {
t.Fatalf("配置保存失败应返回 500,实际为 %d:%s", response.Code, response.Body.String())
}
if _, ok := secrets.values[newCredentialRef]; ok {
t.Fatal("配置保存失败后不应保留新 DNS 凭据")
}
if len(secrets.deleted) != 1 || secrets.deleted[0] != newCredentialRef {
t.Fatalf("配置保存失败后应删除新 DNS 凭据:%v", secrets.deleted)
}
if verifier.consumed {
t.Fatal("配置保存失败后不应消费 DNS 验证令牌")
}
}