补充设置校验测试与任务创建并发保护
This commit is contained in:
parent
4ac123798d
commit
cd80a3fc45
@ -85,6 +85,7 @@ type Server struct {
|
||||
tasks repository.TaskRepository
|
||||
taskMu sync.Mutex
|
||||
taskCancels map[string]context.CancelFunc
|
||||
issueTaskMu sync.Mutex
|
||||
resetMu sync.Mutex
|
||||
resetClosedHash string
|
||||
resetLimiter *resetRateLimiter
|
||||
|
||||
@ -124,6 +124,116 @@ func TestSettingsAPIWebAddressChangeDoesNotRequireRestart(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
server := New(Dependencies{Config: cfg, ConfigPath: configPath})
|
||||
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)
|
||||
}
|
||||
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)
|
||||
|
||||
@ -29,9 +29,11 @@ func (s *Server) issueDomainAsTask(c *gin.Context) {
|
||||
s.writeError(c, http.StatusInternalServerError, "读取域名失败", err)
|
||||
return
|
||||
}
|
||||
s.issueTaskMu.Lock()
|
||||
idempotencyKey := strings.TrimSpace(c.GetHeader("Idempotency-Key"))
|
||||
if idempotencyKey != "" {
|
||||
if !validTaskIdempotencyKey(idempotencyKey) {
|
||||
s.issueTaskMu.Unlock()
|
||||
s.writeError(c, http.StatusBadRequest, "幂等键格式无效", errors.New("幂等键长度或字符无效"))
|
||||
return
|
||||
}
|
||||
@ -41,16 +43,20 @@ func (s *Server) issueDomainAsTask(c *gin.Context) {
|
||||
status = http.StatusConflict
|
||||
}
|
||||
c.JSON(status, gin.H{"task": existing, "replayed": true})
|
||||
s.issueTaskMu.Unlock()
|
||||
return
|
||||
} else if !errors.Is(err, sql.ErrNoRows) {
|
||||
s.issueTaskMu.Unlock()
|
||||
s.writeError(c, http.StatusInternalServerError, "检查幂等任务失败", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if active, err := s.tasks.FindActiveTaskByDomain(c.Request.Context(), domainID, "certificate_issue"); err == nil {
|
||||
s.issueTaskMu.Unlock()
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "该域名已有正在执行的证书任务", "task": active})
|
||||
return
|
||||
} else if !errors.Is(err, sql.ErrNoRows) {
|
||||
s.issueTaskMu.Unlock()
|
||||
s.writeError(c, http.StatusInternalServerError, "检查正在执行的任务失败", err)
|
||||
return
|
||||
}
|
||||
@ -60,9 +66,11 @@ func (s *Server) issueDomainAsTask(c *gin.Context) {
|
||||
Message: "等待执行证书申请", IdempotencyKey: idempotencyKey, CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
if err := s.tasks.CreateTask(c.Request.Context(), task); err != nil {
|
||||
s.issueTaskMu.Unlock()
|
||||
s.writeError(c, http.StatusInternalServerError, "创建证书任务失败", err)
|
||||
return
|
||||
}
|
||||
s.issueTaskMu.Unlock()
|
||||
attempt := repository.TaskAttempt{
|
||||
ID: domain.NewID(), TaskID: task.ID, AttemptNumber: 1, Stage: "certificate_issue", Status: "running",
|
||||
Message: "开始申请证书", StartedAt: &now, CreatedAt: now,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user