保存证书校验后台任务及独立结果的修复前基线

校验结果事务持久化、三库迁移及前端详情接线已实现,完整回归仍在执行。审查发现前端校验幂等键包含路径分隔符,下一批补回归并修复;本提交不是正式发布或生产验收结论。
This commit is contained in:
xuans 2026-10-02 20:25:28 +08:00
parent fce5a84dd4
commit 24a9683c43
27 changed files with 803 additions and 23 deletions

View File

@ -215,6 +215,7 @@ func NewWithConfigPath(ctx context.Context, cfg config.Config, configPath string
ValidateCertificate: func(ctx context.Context, domainID string) (certificate.IssueResult, certificate.ValidationResult, error) {
return trackedWorkflow.Validate(ctx, domainID)
},
ValidateCertificateTask: workflow.ValidateCurrent,
RunRenewal: func(ctx context.Context) (certificate.RenewalReport, error) {
return renewal.RunOnce(ctx)
},

View File

@ -21,6 +21,7 @@ import (
//go:embed migrations/sqlite/0017_deployment_transactions.sql migrations/mysql/0017_deployment_transactions.sql migrations/postgres/0017_deployment_transactions.sql
//go:embed migrations/sqlite/0018_recovery_evidence.sql migrations/mysql/0018_recovery_evidence.sql migrations/postgres/0018_recovery_evidence.sql migrations/sqlite/0019_password_auth_only.sql migrations/mysql/0019_password_auth_only.sql migrations/postgres/0019_password_auth_only.sql
//go:embed migrations/sqlite/0020_task_deployment_reports.sql migrations/mysql/0020_task_deployment_reports.sql migrations/postgres/0020_task_deployment_reports.sql
//go:embed migrations/sqlite/0021_task_validation_results.sql migrations/mysql/0021_task_validation_results.sql migrations/postgres/0021_task_validation_results.sql
var migrationFiles embed.FS
func Migrate(ctx context.Context, db *sql.DB, driver string) error {
@ -45,7 +46,7 @@ func Migrate(ctx context.Context, db *sql.DB, driver string) error {
return nil
}
const latestMigrationVersion = 20
const latestMigrationVersion = 21
// currentMigrationVersion 在执行业务迁移前拒绝新版数据库及不连续的迁移历史。
func currentMigrationVersion(ctx context.Context, db *sql.DB) (int, error) {
@ -85,7 +86,7 @@ func applyMigration(ctx context.Context, db *sql.DB, driver string, version int)
return applySQLiteTaskMigration(ctx, db, statements, version)
}
if driver == "mysql" {
if (version >= 15 && version <= 17) || version == 20 {
if (version >= 15 && version <= 17) || version == 20 || version == 21 {
return applyMySQLDeploymentMigration(ctx, db, version, statements)
}
if version == 14 {
@ -240,6 +241,8 @@ func migrationName(version int) string {
return "password_auth_only"
case 20:
return "task_deployment_reports"
case 21:
return "task_validation_results"
default:
return "unknown"
}

View File

@ -0,0 +1,8 @@
CREATE TABLE task_validation_results (
task_id VARCHAR(36) NOT NULL,
attempt_id VARCHAR(36) NOT NULL,
attempt_number INT NOT NULL,
validation_json MEDIUMTEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (task_id, attempt_id)
);

View File

@ -0,0 +1,8 @@
CREATE TABLE task_validation_results (
task_id TEXT NOT NULL,
attempt_id TEXT NOT NULL,
attempt_number INTEGER NOT NULL,
validation_json TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (task_id, attempt_id)
);

View File

@ -0,0 +1,8 @@
CREATE TABLE task_validation_results (
task_id TEXT NOT NULL,
attempt_id TEXT NOT NULL,
attempt_number INTEGER NOT NULL,
validation_json TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (task_id, attempt_id)
);

View File

@ -8,7 +8,7 @@ import (
)
func TestMySQLDeploymentShapeDefinitions(t *testing.T) {
for _, version := range []int{15, 16, 17, 20} {
for _, version := range []int{15, 16, 17, 20, 21} {
statements := mysqlDeploymentStatements(t, version)
shape, err := parseMySQLDeploymentShape(statements[0])
if err != nil || len(shape.Columns) == 0 || len(shape.Indexes) == 0 {

View File

@ -0,0 +1,67 @@
package database
import (
"fmt"
"testing"
)
func TestMySQLRealTaskValidationMigrationRecovery(t *testing.T) {
f := startRealMySQL(t)
baseline := f.database(t)
if err := Migrate(t.Context(), baseline.DB, "mysql"); err != nil {
t.Fatal(err)
}
expectedSchema := mysqlSchemaSnapshot(t, baseline.DB)
const validation = `{"domains":["example.test"],"fingerprint_sha256":"retained-validation"}`
for _, boundary := range []string{"before_create", "after_create", "with_validation"} {
t.Run(boundary, func(t *testing.T) {
db := f.database(t)
mysqlPrepareVersion(t, db.DB, 20)
if boundary != "before_create" {
mysqlDeploymentExec(t, db.DB, mysqlDeploymentStatements(t, 21)[0])
}
if boundary == "with_validation" {
if _, err := db.DB.ExecContext(t.Context(), `INSERT INTO task_validation_results
(task_id, attempt_id, attempt_number, validation_json) VALUES ('validation-task', 'validation-attempt', 1, ?)`, validation); err != nil {
t.Fatal(err)
}
}
mysqlAssertUpgrade(t, db.DB, expectedSchema)
if boundary == "with_validation" {
var stored string
if err := db.DB.QueryRowContext(t.Context(), `SELECT validation_json FROM task_validation_results
WHERE task_id = 'validation-task' AND attempt_id = 'validation-attempt'`).Scan(&stored); err != nil || stored != validation {
t.Fatalf("恢复迁移后校验详情被改写:%v", err)
}
}
})
}
for _, invalid := range []struct{ name, statement string }{
{"short_result", "ALTER TABLE task_validation_results MODIFY COLUMN validation_json TEXT NOT NULL"},
{"wrong_primary_order", "ALTER TABLE task_validation_results DROP PRIMARY KEY, ADD PRIMARY KEY (attempt_id, task_id)"},
{"nullable_attempt", "ALTER TABLE task_validation_results MODIFY COLUMN attempt_number INT NULL"},
{"wrong_engine", "ALTER TABLE task_validation_results ENGINE=MyISAM"},
{"extra_column", "ALTER TABLE task_validation_results ADD COLUMN unexpected INT NOT NULL DEFAULT 0"},
} {
t.Run(invalid.name, func(t *testing.T) {
db := f.database(t)
mysqlPrepareVersion(t, db.DB, 20)
mysqlDeploymentExec(t, db.DB, mysqlDeploymentStatements(t, 21)[0])
if _, err := db.DB.ExecContext(t.Context(), `INSERT INTO task_validation_results
(task_id, attempt_id, attempt_number, validation_json) VALUES ('kept-task', 'kept-attempt', 1, ?)`, validation); err != nil {
t.Fatal(err)
}
mysqlDeploymentExec(t, db.DB, invalid.statement)
before := fmt.Sprint(mysqlSchemaSnapshot(t, db.DB))
assertMySQLDeploymentMigrationRejected(t, db.DB, 20)
if after := fmt.Sprint(mysqlSchemaSnapshot(t, db.DB)); after != before {
t.Fatal("校验结果表冲突不得被自动覆盖或修复")
}
var stored string
if err := db.DB.QueryRowContext(t.Context(), `SELECT validation_json FROM task_validation_results
WHERE task_id = 'kept-task' AND attempt_id = 'kept-attempt'`).Scan(&stored); err != nil || stored != validation {
t.Fatalf("拒绝迁移时不得修改已有校验结果:%v", err)
}
})
}
}

View File

@ -94,6 +94,10 @@ func (s *Server) listCertificateVersions(c *gin.Context) {
}
func (s *Server) validateCertificate(c *gin.Context) {
if s.validateCertificateTask != nil {
s.submitDomainTask(c, "certificate_validate")
return
}
if !s.authorizeVisibleDomainPermission(c, domain.PermissionPrivateKeyRead) {
return
}

View File

@ -0,0 +1,146 @@
package httpapi
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
"git.pcux.cn/xuans/Scert/backend/internal/repository"
)
func TestAsyncValidationLifecycle(t *testing.T) {
for _, scenario := range []string{"success", "failure", "panic", "cancel", "shutdown", "replay", "stopped", "full"} {
t.Run(scenario, func(t *testing.T) {
server, tasks, _, cleanup := newTaskAPITestServer(t, nil)
defer cleanup()
var calls atomic.Int32
server.validateCertificateTask = func(ctx context.Context, id string) (certificate.IssueResult, certificate.ValidationResult, error) {
calls.Add(1)
if ctx.Err() != nil || id != "domain-1" {
t.Error("后台校验沿用已断开的请求或错误域名")
}
if scenario == "panic" {
panic("private-validation-detail")
}
if scenario == "failure" {
return certificate.IssueResult{}, certificate.ValidationResult{}, errors.New("private-validation-detail")
}
return certificate.IssueResult{Saved: certificate.SavedResource{PrivateKeyPath: "/private-path/key.pem"}},
certificate.ValidationResult{Domains: []string{"example.com"}, FingerprintSHA256: "validated-fingerprint", ChainLength: 2}, nil
}
server.asyncTasks = make(map[string]bool)
server.taskSlots = make(chan struct{}, 1)
server.taskSlots <- struct{}{}
if scenario == "stopped" {
server.stopTaskWorkers()
}
if scenario == "full" {
for index := range 32 {
server.asyncTasks[string(rune('a'+index))] = true
}
}
response := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/certificate/validate", nil)
request.Header.Set("Idempotency-Key", "validation-request")
ctx, cancel := context.WithCancel(request.Context())
serveAdminHTTP(server, response, request.WithContext(ctx))
cancel()
if scenario == "stopped" || scenario == "full" {
if response.Code != http.StatusServiceUnavailable || calls.Load() != 0 {
t.Fatalf("服务拒绝后仍执行校验:%d %d", response.Code, calls.Load())
}
return
}
task := submittedTask(t, response)
if task.TaskType != "certificate_validate" || calls.Load() != 0 {
t.Fatalf("校验没有正确排队:%+v %d", task, calls.Load())
}
if scenario == "replay" {
replayed := httptest.NewRecorder()
serveAdminHTTP(server, replayed, request)
if replayed.Code != http.StatusConflict || !strings.Contains(replayed.Body.String(), task.ID) || !strings.Contains(replayed.Body.String(), `"replayed":true`) {
t.Fatalf("校验重复请求没有复用原任务:%d %s", replayed.Code, replayed.Body.String())
}
}
switch scenario {
case "cancel":
cancelled := httptest.NewRecorder()
serveAdminHTTP(server, cancelled, httptest.NewRequest(http.MethodDelete, "/api/v1/tasks/"+task.ID, nil))
if cancelled.Code != http.StatusAccepted {
t.Fatalf("取消排队校验失败:%d", cancelled.Code)
}
case "shutdown":
server.stopTaskWorkers()
default:
<-server.taskSlots
}
waitTaskWorkersForTest(t, server)
want, wantCalls := "success", int32(1)
if scenario == "failure" || scenario == "panic" {
want = "failed"
}
if scenario == "cancel" || scenario == "shutdown" {
want, wantCalls = "cancelled", 0
}
stored, err := tasks.GetTask(t.Context(), task.ID)
if err != nil || stored.Status != want || calls.Load() != wantCalls || strings.Contains(stored.Message, "private-") {
t.Fatalf("校验终态或调用次数错误:%+v %v %d", stored, err, calls.Load())
}
result := httptest.NewRecorder()
serveAdminHTTP(server, result, httptest.NewRequest(http.MethodGet, "/api/v1/tasks/"+task.ID+"/validation-results", nil))
var body struct {
Items []repository.TaskValidationResult `json:"items"`
}
if result.Code != http.StatusOK || json.Unmarshal(result.Body.Bytes(), &body) != nil || strings.Contains(result.Body.String(), "private-") {
t.Fatalf("读取校验结果失败或泄露内部内容:%d %s", result.Code, result.Body.String())
}
if want == "success" {
if len(body.Items) != 1 || body.Items[0].Validation.FingerprintSHA256 != "validated-fingerprint" {
t.Fatalf("后台校验详情丢失:%+v", body)
}
} else if len(body.Items) != 0 {
t.Fatal("未通过校验却保存通过结果")
}
reports, err := tasks.ListTaskDeploymentReports(t.Context(), task.ID)
if err != nil || len(reports) != 0 {
t.Fatalf("校验生成伪部署报告:%+v %v", reports, err)
}
})
}
}
func TestAsyncValidationRequiresOnlyExistingPrivateKeyPermission(t *testing.T) {
for _, allowed := range []bool{true, false} {
server, _, _, cleanup := newTaskAPITestServer(t, nil)
server.validateCertificateTask = func(context.Context, string) (certificate.IssueResult, certificate.ValidationResult, error) {
return certificate.IssueResult{}, certificate.ValidationResult{Domains: []string{"example.com"}}, nil
}
server.projectMembers = permissionProjectMemberRepository{
ProjectMemberRepository: server.projectMembers,
allow: func(permission domain.ProjectPermission) error {
if allowed && permission == domain.PermissionPrivateKeyRead {
return nil
}
return repository.ErrProjectAccessDenied
},
}
response := httptest.NewRecorder()
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/certificate/validate", nil))
want := http.StatusForbidden
if allowed {
want = http.StatusAccepted
}
if response.Code != want {
t.Fatalf("校验改变了原有权限要求:%d %s", response.Code, response.Body.String())
}
waitTaskWorkersForTest(t, server)
cleanup()
}
}

View File

@ -67,6 +67,8 @@ type Dependencies struct {
DeployCertificateTask func(context.Context, string) (certificate.IssueResult, deployment.Report, error)
RestoreCertificateVersion func(context.Context, string, string) (certificate.IssueResult, error)
ValidateCertificate func(context.Context, string) (certificate.IssueResult, certificate.ValidationResult, error)
// ValidateCertificateTask 直接校验当前证书,不重复创建任务。
ValidateCertificateTask func(context.Context, string) (certificate.IssueResult, certificate.ValidationResult, error)
RunRenewal func(context.Context) (certificate.RenewalReport, error)
UpdateRenewalPolicy func(certificate.RenewalPolicy)
@ -106,6 +108,7 @@ type Server struct {
deployCertificateTask func(context.Context, string) (certificate.IssueResult, deployment.Report, error)
restoreVersion func(context.Context, string, string) (certificate.IssueResult, error)
validateCertificateWorkflow func(context.Context, string) (certificate.IssueResult, certificate.ValidationResult, error)
validateCertificateTask func(context.Context, string) (certificate.IssueResult, certificate.ValidationResult, error)
runRenewal func(context.Context) (certificate.RenewalReport, error)
updateRenewalPolicy func(certificate.RenewalPolicy)
@ -175,6 +178,7 @@ func New(deps Dependencies) *Server {
deployCertificateTask: deps.DeployCertificateTask,
restoreVersion: deps.RestoreCertificateVersion,
validateCertificateWorkflow: deps.ValidateCertificate,
validateCertificateTask: deps.ValidateCertificateTask,
runRenewal: deps.RunRenewal,
updateRenewalPolicy: deps.UpdateRenewalPolicy,
currentCertificate: deps.CurrentCertificate,
@ -231,6 +235,7 @@ func (s *Server) registerRoutes() {
api.POST("/tasks/:id/retry", s.retryTask)
api.GET("/tasks/:id/attempts", s.listTaskAttempts)
api.GET("/tasks/:id/deployment-results", s.listTaskDeploymentResults)
api.GET("/tasks/:id/validation-results", s.listTaskValidationResults)
adminAPI.GET("/settings", s.getSettings)
adminAPI.PUT("/settings", s.requireSuperAdminSystemConfig, s.updateSettings)

View File

@ -38,7 +38,7 @@ func (s *Server) submitPreparedDomainTask(c *gin.Context, taskType string, prepa
prepared.cleanup()
}
}()
if (taskType == "certificate_issue" && s.issueCertificate == nil) || (taskType == "certificate_deploy" && s.deployCertificateTask == nil) {
if (taskType == "certificate_issue" && s.issueCertificate == nil) || (taskType == "certificate_deploy" && s.deployCertificateTask == nil) || (taskType == "certificate_validate" && s.validateCertificateTask == nil) {
s.writeError(c, http.StatusNotImplemented, "证书申请接口尚未配置", errors.New("证书申请工作流未配置"))
return
}
@ -75,9 +75,17 @@ func (s *Server) submitPreparedDomainTask(c *gin.Context, taskType string, prepa
return
}
}
if err := authorizer.AuthorizeProject(c.Request.Context(), c.GetString(authenticatedActorKey), resource.ResourceScope.Normalize().ProjectID, domain.PermissionDeploymentExecute); err != nil {
permission := domain.PermissionDeploymentExecute
if taskType == "certificate_validate" {
permission = domain.PermissionPrivateKeyRead
}
if err := authorizer.AuthorizeProject(c.Request.Context(), c.GetString(authenticatedActorKey), resource.ResourceScope.Normalize().ProjectID, permission); err != nil {
if errors.Is(err, repository.ErrProjectAccessDenied) {
s.writeError(c, http.StatusForbidden, "无权执行此项目的证书部署任务", nil)
message := "无权执行此项目的证书部署任务"
if taskType == "certificate_validate" {
message = "无权读取此项目的私钥进行证书校验"
}
s.writeError(c, http.StatusForbidden, message, nil)
return
}
s.writeError(c, http.StatusInternalServerError, "检查证书部署权限失败", err)
@ -176,6 +184,10 @@ func (s *Server) submitPreparedDomainTask(c *gin.Context, taskType string, prepa
s.executeCertificateDeployTask(c, task, attempt, taskType)
return
}
if taskType == "certificate_validate" {
s.executeCertificateValidationTask(c, task, attempt)
return
}
s.executeCertificateIssueTask(c, task, attempt, taskType)
}
@ -192,6 +204,19 @@ func (s *Server) executeCertificateDeployTask(c *gin.Context, task repository.Ta
}
func (s *Server) runCertificateTask(ctx context.Context, task repository.Task, attempt repository.TaskAttempt, auditAction string, operation func(context.Context, string) (certificate.IssueResult, deployment.Report, error), stage, runningMessage, successMessage string) {
s.runCertificateOperation(ctx, task, attempt, auditAction, func(ctx context.Context, id string) (certificateTaskResult, error) {
result, report, err := operation(ctx, id)
return certificateTaskResult{Certificate: result, Deployment: &report}, err
}, stage, runningMessage, successMessage)
}
type certificateTaskResult struct {
Certificate certificate.IssueResult
Deployment *deployment.Report
Validation *certificate.ValidationResult
}
func (s *Server) runCertificateOperation(ctx context.Context, task repository.Task, attempt repository.TaskAttempt, auditAction string, operation func(context.Context, string) (certificateTaskResult, error), stage, runningMessage, successMessage string) {
executionCtx := deployment.WithTransactionContext(ctx, deployment.TransactionContext{
TaskID: task.ID, AttemptID: attempt.ID, AttemptNumber: attempt.AttemptNumber,
})
@ -241,7 +266,12 @@ func (s *Server) runCertificateTask(ctx context.Context, task repository.Task, a
if s.finishCancelledTask(task, attempt, executionCtx) {
return
}
certificateResult, deploymentReport, issueErr := operation(executionCtx, task.DomainID)
result, issueErr := operation(executionCtx, task.DomainID)
certificateResult := result.Certificate
var deploymentReport deployment.Report
if result.Deployment != nil {
deploymentReport = *result.Deployment
}
if task.TaskType == "certificate_import" {
deploymentReport.CertificateImported = issueErr == nil || (certificateResult.DomainID == task.DomainID &&
certificateResult.Saved.CertificatePath != "" && certificateResult.Saved.FullChainPath != "" && certificateResult.Saved.PrivateKeyPath != "")
@ -275,6 +305,15 @@ func (s *Server) runCertificateTask(ctx context.Context, task repository.Task, a
message = "证书已导入,但部署未完成。请检查部署结果后重试部署,无需重新导入。"
}
}
if task.TaskType == "certificate_validate" && issueErr != nil {
message = "证书校验失败,请检查当前证书、私钥匹配及证书链"
if status == "cancelled" {
message = "证书校验已取消"
}
if errors.Is(issueErr, context.DeadlineExceeded) {
message = "证书校验超时"
}
}
s.taskMu.Lock()
defer s.taskMu.Unlock()
if persisted, err := s.tasks.GetTask(finishCtx, task.ID); err != nil {
@ -287,13 +326,23 @@ func (s *Server) runCertificateTask(ctx context.Context, task repository.Task, a
} else if status == "cancelled" {
status = "cancelled"
message = "任务已停止;已执行操作是否回滚请查看部署结果"
if task.TaskType == "certificate_validate" {
message = "证书校验已停止,未保存通过结果"
}
}
}
if err := s.tasks.FinishTaskAttempt(finishCtx, repository.TaskCompletion{
completion := repository.TaskCompletion{
TaskID: task.ID, AttemptID: attempt.ID, Status: status, Stage: stage,
Message: message, StartedAt: &now, FinishedAt: finished,
Report: &deploymentReport, AuditAction: auditAction,
}); err != nil && s.logger != nil {
AuditAction: auditAction,
}
if result.Deployment != nil {
completion.Report = &deploymentReport
}
if status == "success" {
completion.Validation = result.Validation
}
if err := s.tasks.FinishTaskAttempt(finishCtx, completion); err != nil && s.logger != nil {
s.logger.Error("保存任务及尝试终态失败,保留中断恢复状态", "task_id", task.ID, "error", err)
}
}

View File

@ -28,6 +28,8 @@ func (s *Server) authorizeCertificateTaskExecution(ctx context.Context, task rep
case "certificate_deploy":
case "certificate_import":
permissions = append(permissions, domain.PermissionCertificateManage)
case "certificate_validate":
permissions = []domain.ProjectPermission{domain.PermissionPrivateKeyRead}
default:
return repository.ErrProjectAccessDenied
}

View File

@ -17,11 +17,11 @@ import (
)
func TestAsyncExecutionRechecksQueuedAuthorization(t *testing.T) {
for _, taskType := range []string{"certificate_issue", "certificate_deploy", "certificate_import"} {
for _, taskType := range []string{"certificate_issue", "certificate_deploy", "certificate_import", "certificate_validate"} {
for _, scenario := range []string{
"unchanged", "disabled_user", "removed_member", "read_only_member",
"moved_domain", "deleted_domain", "deployment_denied", "certificate_denied",
"dns_denied", "authorization_error", "missing_authorizer", "missing_reader",
"dns_denied", "private_key_denied", "authorization_error", "missing_authorizer", "missing_reader",
} {
t.Run(taskType+"/"+scenario, func(t *testing.T) {
var calls atomic.Int32
@ -32,6 +32,10 @@ func TestAsyncExecutionRechecksQueuedAuthorization(t *testing.T) {
server, tasks, audit, cleanup := newTaskAPITestServer(t, operation)
defer cleanup()
server.deployCertificateTask = operation
server.validateCertificateTask = func(context.Context, string) (certificate.IssueResult, certificate.ValidationResult, error) {
calls.Add(1)
return certificate.IssueResult{}, certificate.ValidationResult{Domains: []string{"example.com"}}, nil
}
server.importCertificateTask = func(ctx context.Context, request certificate.ImportRequest) (certificate.IssueResult, deployment.Report, error) {
return operation(ctx, request.DomainID)
}
@ -45,6 +49,9 @@ func TestAsyncExecutionRechecksQueuedAuthorization(t *testing.T) {
if taskType == "certificate_import" {
path = "/api/v1/domains/domain-1/certificate/import"
}
if taskType == "certificate_validate" {
path = "/api/v1/domains/domain-1/certificate/validate"
}
response := httptest.NewRecorder()
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, path,
strings.NewReader(`{"certificate_path":"/managed/cert.pem","private_key_path":"/managed/key.pem"}`)))
@ -78,7 +85,7 @@ func TestAsyncExecutionRechecksQueuedAuthorization(t *testing.T) {
server.projectMembers = nil
case "missing_reader":
server.projectResources = nil
case "deployment_denied", "certificate_denied", "dns_denied", "authorization_error":
case "deployment_denied", "certificate_denied", "dns_denied", "private_key_denied", "authorization_error":
server.projectMembers = permissionProjectMemberRepository{
ProjectMemberRepository: server.projectMembers,
allow: func(permission domain.ProjectPermission) error {
@ -87,7 +94,8 @@ func TestAsyncExecutionRechecksQueuedAuthorization(t *testing.T) {
}
if scenario == "deployment_denied" && permission == domain.PermissionDeploymentExecute ||
scenario == "certificate_denied" && permission == domain.PermissionCertificateManage ||
scenario == "dns_denied" && permission == domain.PermissionDNSCredentialUse {
scenario == "dns_denied" && permission == domain.PermissionDNSCredentialUse ||
scenario == "private_key_denied" && permission == domain.PermissionPrivateKeyRead {
return repository.ErrProjectAccessDenied
}
return nil
@ -102,6 +110,9 @@ func TestAsyncExecutionRechecksQueuedAuthorization(t *testing.T) {
<-server.taskSlots
waitTaskWorkersForTest(t, server)
want := "failed"
if taskType == "certificate_validate" && (scenario == "deployment_denied" || scenario == "certificate_denied" || scenario == "dns_denied") || taskType != "certificate_validate" && scenario == "private_key_denied" {
want = "success"
}
if scenario == "unchanged" || taskType == "certificate_deploy" && (scenario == "dns_denied" || scenario == "certificate_denied") || taskType == "certificate_import" && scenario == "dns_denied" {
want = "success"
}

View File

@ -16,6 +16,12 @@ import (
// 持久化任务与尝试后才调用此入口;后台执行不得持有请求或响应对象。
// 进程中断由启动恢复标记失败,不自动重放可能已经签发或部署的外部操作。
func (s *Server) enqueueCertificateTask(c *gin.Context, task repository.Task, attempt repository.TaskAttempt, action string, operation func(context.Context, string) (certificate.IssueResult, deployment.Report, error), stage, runningMessage, successMessage string, cleanup func()) bool {
return s.enqueueTaskWorker(c, task, attempt, func(ctx context.Context) {
s.runCertificateTask(ctx, task, attempt, action, operation, stage, runningMessage, successMessage)
}, cleanup)
}
func (s *Server) enqueueTaskWorker(c *gin.Context, task repository.Task, attempt repository.TaskAttempt, execute func(context.Context), cleanup func()) bool {
s.taskMu.Lock()
if s.taskWorkersStopped || len(s.asyncTasks) >= 32 {
s.taskMu.Unlock()
@ -44,7 +50,11 @@ func (s *Server) enqueueCertificateTask(c *gin.Context, task repository.Task, at
defer cancel()
defer func() {
if recover() != nil {
s.finishTaskBeforeExecution(task, attempt, "failed", "任务执行异常中断,请检查部署恢复状态后重试")
message := "任务执行异常中断,请检查部署恢复状态后重试"
if task.TaskType == "certificate_validate" {
message = "证书校验异常中断,未保存通过结果"
}
s.finishTaskBeforeExecution(task, attempt, "failed", message)
}
}()
select {
@ -54,7 +64,7 @@ func (s *Server) enqueueCertificateTask(c *gin.Context, task repository.Task, at
s.finishTaskContext(task, attempt, ctx.Err())
return
}
s.runCertificateTask(ctx, task, attempt, action, operation, stage, runningMessage, successMessage)
execute(ctx)
}()
return true
}

View File

@ -0,0 +1,53 @@
package httpapi
import (
"context"
"database/sql"
"errors"
"net/http"
"github.com/gin-gonic/gin"
"git.pcux.cn/xuans/Scert/backend/internal/repository"
)
func (s *Server) executeCertificateValidationTask(c *gin.Context, task repository.Task, attempt repository.TaskAttempt) {
if s.enqueueTaskWorker(c, task, attempt, func(ctx context.Context) {
s.runCertificateOperation(ctx, task, attempt, task.TaskType, func(ctx context.Context, id string) (certificateTaskResult, error) {
_, validation, err := s.validateCertificateTask(ctx, id)
if err != nil {
return certificateTaskResult{}, err
}
return certificateTaskResult{Validation: &validation}, nil
}, "certificate_validate", "正在校验证书、私钥匹配及证书链", "证书校验通过")
}, nil) {
writeAcceptedCertificateTask(c, task)
}
}
func (s *Server) listTaskValidationResults(c *gin.Context) {
reader, ok := s.projectTaskReader(c)
if !ok {
return
}
actorID := c.GetString(authenticatedActorKey)
if _, err := reader.GetVisibleTask(c.Request.Context(), actorID, c.Param("id")); err != nil {
if errors.Is(err, sql.ErrNoRows) || errors.Is(err, repository.ErrProjectAccessDenied) {
s.writeError(c, http.StatusForbidden, "无权访问此任务", nil)
return
}
s.writeError(c, http.StatusInternalServerError, "读取任务失败", err)
return
}
validationReader, ok := s.tasks.(repository.TaskValidationReader)
if !ok {
s.writeError(c, http.StatusNotImplemented, "任务校验结果接口尚未配置", nil)
return
}
items, err := validationReader.ListVisibleTaskValidations(c.Request.Context(), actorID, c.Param("id"))
if err != nil {
s.writeError(c, http.StatusInternalServerError, "查询任务校验结果失败", err)
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}

View File

@ -8,6 +8,7 @@ import (
"strings"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
)
@ -21,6 +22,7 @@ type TaskCompletion struct {
StartedAt *time.Time
FinishedAt time.Time
Report *deployment.Report
Validation *certificate.ValidationResult
AuditAction string
Retry bool
WithoutAttempt bool
@ -41,7 +43,7 @@ func (r *SQLTaskRepository) FinishTaskAttempt(ctx context.Context, completion Ta
default:
return errors.New("任务终态无效")
}
if completion.WithoutAttempt && (completion.AttemptID != "" || completion.Report != nil || completion.Retry || (completion.Status != "failed" && completion.Status != "cancelled")) {
if completion.WithoutAttempt && (completion.AttemptID != "" || completion.Report != nil || completion.Validation != nil || completion.Retry || (completion.Status != "failed" && completion.Status != "cancelled")) {
return errors.New("无活动尝试的收尾参数无效")
}
if completion.Retry && (completion.Status != "failed" || completion.AuditAction != "") {
@ -82,6 +84,11 @@ func (r *SQLTaskRepository) FinishTaskAttempt(ctx context.Context, completion Ta
return err
}
}
if completion.Validation != nil {
if err := r.recordCompletionValidation(ctx, tx, completion); err != nil {
return err
}
}
if completion.AuditAction != "" {
if err := r.recordCompletionAudit(ctx, tx, completion); err != nil {
return err

View File

@ -0,0 +1,86 @@
package repository
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
)
// TaskValidationResult 仅保存已校验的公开元数据,不包含证书路径和私钥。
type TaskValidationResult struct {
TaskID string `json:"task_id"`
AttemptID string `json:"attempt_id"`
AttemptNumber int `json:"attempt_number"`
Validation certificate.ValidationResult `json:"validation"`
CreatedAt time.Time `json:"created_at"`
}
type TaskValidationReader interface {
ListVisibleTaskValidations(context.Context, string, string) ([]TaskValidationResult, error)
}
func (r *SQLTaskRepository) recordCompletionValidation(ctx context.Context, tx *sql.Tx, completion TaskCompletion) error {
if completion.Report != nil || completion.Status != "success" || completion.Retry {
return errors.New("校验元数据只能随独立校验任务成功提交")
}
var attemptNumber int
var taskType string
if err := tx.QueryRowContext(ctx, r.query(`SELECT attempts.attempt_number, tasks.task_type
FROM task_attempts attempts JOIN tasks ON tasks.id = attempts.task_id
WHERE attempts.id = ? AND tasks.id = ?`), completion.AttemptID, completion.TaskID).Scan(&attemptNumber, &taskType); err != nil {
return fmt.Errorf("读取校验结果所属任务失败:%w", err)
}
if taskType != "certificate_validate" {
return errors.New("校验结果不能保存到其他类型任务")
}
content, err := json.Marshal(completion.Validation)
if err != nil {
return fmt.Errorf("序列化校验结果失败:%w", err)
}
if _, err := tx.ExecContext(ctx, r.query(`INSERT INTO task_validation_results
(task_id, attempt_id, attempt_number, validation_json, created_at) VALUES (?, ?, ?, ?, ?)`),
completion.TaskID, completion.AttemptID, attemptNumber, string(content), completion.FinishedAt.UTC()); err != nil {
return fmt.Errorf("保存校验结果失败:%w", err)
}
return nil
}
func (r *SQLTaskRepository) ListVisibleTaskValidations(ctx context.Context, actorID, taskID string) ([]TaskValidationResult, error) {
if r == nil || r.db == nil {
return nil, errors.New("数据库连接不能为空")
}
predicate, args := taskVisibilityPredicate("tasks", domain.PermissionTaskRead)
args[0] = strings.TrimSpace(actorID)
args = append(args, strings.TrimSpace(taskID))
rows, err := r.db.QueryContext(ctx, r.query(`SELECT results.task_id, results.attempt_id, results.attempt_number,
results.validation_json, results.created_at FROM task_validation_results results
JOIN tasks ON tasks.id = results.task_id WHERE `+predicate+` AND results.task_id = ? ORDER BY results.attempt_number`), args...)
if err != nil {
return nil, fmt.Errorf("查询任务校验结果失败:%w", err)
}
defer rows.Close()
items := make([]TaskValidationResult, 0)
for rows.Next() {
var item TaskValidationResult
var content string
if err := rows.Scan(&item.TaskID, &item.AttemptID, &item.AttemptNumber, &content, &item.CreatedAt); err != nil {
return nil, fmt.Errorf("读取任务校验结果失败:%w", err)
}
if err := json.Unmarshal([]byte(content), &item.Validation); err != nil {
return nil, fmt.Errorf("解析任务校验结果失败:%w", err)
}
item.CreatedAt = item.CreatedAt.UTC()
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("读取任务校验结果列表失败:%w", err)
}
return items, nil
}

View File

@ -0,0 +1,110 @@
package repository
import (
"testing"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
)
func TestTaskValidationPersistsAndEnforcesVisibility(t *testing.T) {
repo, db := newTaskRepositoryTest(t)
defer db.Close()
completion := seedTaskCompletion(t, repo)
if _, err := db.ExecContext(t.Context(), `UPDATE tasks SET task_type = 'certificate_validate' WHERE id = ?`, completion.TaskID); err != nil {
t.Fatal(err)
}
if _, err := db.ExecContext(t.Context(), `INSERT INTO users (id, username, password_hash, role, status)
VALUES ('reader', 'reader', 'test-only', 'user', 'active'), ('outsider', 'outsider', 'test-only', 'user', 'active')`); err != nil {
t.Fatal(err)
}
if _, err := db.ExecContext(t.Context(), `INSERT INTO project_members (project_id, user_id, role) VALUES ('default', 'reader', 'read_only')`); err != nil {
t.Fatal(err)
}
completion.Stage = "certificate_validate"
completion.AuditAction = "certificate_validate"
completion.Validation = &certificate.ValidationResult{
Domains: []string{"example.test", "*.example.test"}, SerialNumber: "1234",
FingerprintSHA256: "test-fingerprint", NotBefore: time.Now().UTC().Truncate(time.Second),
NotAfter: time.Now().UTC().Add(24 * time.Hour).Truncate(time.Second), ChainLength: 2,
}
if err := repo.FinishTaskAttempt(t.Context(), completion); err != nil {
t.Fatal(err)
}
reader := NewSQLTaskRepository(db)
items, err := reader.ListVisibleTaskValidations(t.Context(), "reader", completion.TaskID)
if err != nil || len(items) != 1 || items[0].AttemptID != completion.AttemptID || items[0].AttemptNumber != 1 {
t.Fatalf("重建仓储后校验结果丢失:%+v %v", items, err)
}
got := items[0].Validation
if len(got.Domains) != 2 || got.SerialNumber != "1234" || got.FingerprintSHA256 != "test-fingerprint" || got.ChainLength != 2 || !got.NotAfter.Equal(completion.Validation.NotAfter) {
t.Fatalf("校验详情不完整:%+v", got)
}
for _, actor := range []string{"outsider", "", "missing"} {
items, err := reader.ListVisibleTaskValidations(t.Context(), actor, completion.TaskID)
if err != nil || len(items) != 0 {
t.Fatalf("无权限账户读取校验结果:%s %+v %v", actor, items, err)
}
}
if _, err := db.ExecContext(t.Context(), `DELETE FROM project_members WHERE user_id = 'reader'`); err != nil {
t.Fatal(err)
}
items, err = reader.ListVisibleTaskValidations(t.Context(), "reader", completion.TaskID)
if err != nil || len(items) != 0 {
t.Fatalf("撤权后仍可读取校验结果:%+v %v", items, err)
}
reports, err := repo.ListTaskDeploymentReports(t.Context(), completion.TaskID)
if err != nil || len(reports) != 0 {
t.Fatalf("校验结果被伪装成部署报告:%+v %v", reports, err)
}
}
func TestTaskValidationFailureRollsBackAllRecords(t *testing.T) {
for _, scenario := range []string{"result_insert", "audit_insert", "wrong_task_type", "mixed_report", "failed_status", "foreign_attempt"} {
t.Run(scenario, func(t *testing.T) {
repo, db := newTaskRepositoryTest(t)
defer db.Close()
completion := seedTaskCompletion(t, repo)
completion.Validation = &certificate.ValidationResult{Domains: []string{"example.test"}}
completion.AuditAction = "certificate_validate"
if scenario != "wrong_task_type" {
if _, err := db.ExecContext(t.Context(), `UPDATE tasks SET task_type = 'certificate_validate' WHERE id = ?`, completion.TaskID); err != nil {
t.Fatal(err)
}
}
switch scenario {
case "result_insert", "audit_insert":
table := "task_validation_results"
if scenario == "audit_insert" {
table = "audit_logs"
}
if _, err := db.ExecContext(t.Context(), `CREATE TRIGGER reject_validation BEFORE INSERT ON `+table+` BEGIN SELECT RAISE(ABORT, 'injected failure'); END`); err != nil {
t.Fatal(err)
}
case "mixed_report":
completion.Report = completionTestReport()
case "failed_status":
completion.Status = "failed"
case "foreign_attempt":
completion.AttemptID = "missing-attempt"
}
if err := repo.FinishTaskAttempt(t.Context(), completion); err == nil {
t.Fatal("无效校验结果或写入失败必须回滚终态")
}
task, err := repo.GetTask(t.Context(), completion.TaskID)
if err != nil || task.Status != "running" || task.FinishedAt != nil {
t.Fatalf("失败后任务被部分提交:%+v %v", task, err)
}
attempts, err := repo.ListAttempts(t.Context(), completion.TaskID)
if err != nil || len(attempts) != 1 || attempts[0].Status != "running" || attempts[0].FinishedAt != nil {
t.Fatalf("失败后尝试被部分提交:%+v %v", attempts, err)
}
for _, table := range []string{"task_validation_results", "task_deployment_reports", "task_deployment_results", "audit_logs"} {
var count int
if err := db.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM `+table+` WHERE task_id = ?`, completion.TaskID).Scan(&count); err != nil || count != 0 {
t.Fatalf("失败后出现部分记录:%s %d %v", table, count, err)
}
}
})
}
}

View File

@ -1,5 +1,15 @@
# Scert 整改任务执行清单
## 2026年10月2日 证书校验后台任务执行计划
本批需求沿用已确认的 P0-10、P1-04、P2-03 清单:先开发和自动回归,再统一生产部署;不处理 Windows。修改前已提交并推送 fce5a84。
- [~] 校验入口接入持久化后台任务;提交和执行前均检查现有私钥读取权限,不误加部署或 DNS 权限。
- [ ] 校验元数据独立持久化,与任务、尝试和审计终态同事务提交;不伪装部署报告,不保存文件路径或私钥。
- [ ] 前端跟踪校验任务,在任务详情展示校验域名、指纹、有效期、序列号和链长度;刷新与撤权保持真实状态。
- [ ] 补充异步生命周期、事务故障回滚、实时权限和前端回归;核对迁移兼容性并更新接口说明及测试记录。
- [!] 本批通过不关闭历史恢复、其他长操作、升级、通知重置、三库、备份恢复及最终生产门禁。
## 2026年10月2日19:34 证书导入异步化回归完成,等待复审
需求沿用已确认的 Linux 完整清单,先开发及自动回归,再统一部署,不处理 Windows。本批修改前已确认工作区干净并推送 09616e7 基线,无额外待提交文件。

View File

@ -588,7 +588,7 @@ export function useDashboard() {
notice.value = "";
try {
const options = { method: "POST" };
if (action === "issue" || action === "deploy") {
if (action === "issue" || action === "deploy" || action === "certificate/validate") {
const suffix = crypto.randomUUID ? crypto.randomUUID() : createRandomSuffix();
options.headers = { "Idempotency-Key": `${action}-${item.id}-${suffix}` };
}
@ -597,7 +597,8 @@ export function useDashboard() {
if (response.status === 202 && !result.task?.id) {
throw new Error("服务未返回任务编号,请在运行概览确认是否已接收,勿重复提交");
}
if (response.ok && result.task?.id && (response.status === 202 || result.accepted || result.replayed)) {
if ((response.ok || response.status === 409 && result.replayed) && result.task?.id &&
(response.status === 202 || result.accepted || result.replayed)) {
notice.value = `任务已提交:${result.task.id},可在运行概览查看执行结果`;
if (action === "deploy") {
deploymentResult.value = {
@ -606,6 +607,7 @@ export function useDashboard() {
taskMessage: result.task.message || "", report: null, error: "", queryError: "",
};
}
if (action === "certificate/validate") await taskMonitor.openTaskDetails(result.task);
return;
}
if (action === "deploy") {

View File

@ -141,6 +141,7 @@ test("完整页面逐个渲染六个板块,每次只有一个内容标题", as
app.component("DNSAccounts", { render: () => Vue.h("h2", headings["dns-accounts"]) });
app.component("DeploymentResult", { render: () => null });
app.component("TaskDeploymentResults", { render: () => null });
app.component("TaskValidationResult", { render: () => null });
const warnings = [];
app.config.warnHandler = (message) => warnings.push(message);
const html = await renderToString(app);

View File

@ -11,6 +11,7 @@ import { useDashboardNavigation } from "./DashboardNavigation.js";
import { useDashboard } from "./Dashboard.js";
import DeploymentResult from "./DeploymentResult.vue";
import TaskDeploymentResults from "./TaskDeploymentResults.vue";
import TaskValidationResult from "./TaskValidationResult.vue";
const dashboard = useDashboard();
const {
@ -40,6 +41,7 @@ const {
notice,
taskQueryError,
taskDeploymentReport,
taskValidationResult,
formError,
showForm,
editingId,
@ -521,6 +523,10 @@ const { activeSection, activeLabel, sections, selectSection } = useDashboardNavi
<p>{{ taskDetails.message || "无附加信息" }}</p>
</div>
<TaskDeploymentResults v-if="taskDeploymentReport" :report="taskDeploymentReport" />
<TaskValidationResult v-if="taskValidationResult" :result="taskValidationResult" />
<p v-else-if="taskDetails.task_type === 'certificate_validate' && taskDetails.status === 'success'" class="muted">
该任务没有留存校验详情,不据此推断当前证书仍然有效。
</p>
<h3 class="modal-section-title">执行尝试</h3>
<div v-if="taskAttempts.length === 0" class="empty-state compact-empty">暂无执行尝试记录</div>
<div v-else class="attempt-list">

View File

@ -6,6 +6,16 @@ export function taskIsPending(task) {
return !terminalStatuses.has(task?.status);
}
export function latestValidationResult(task, attempts, results) {
if (task.task_type !== "certificate_validate" || task.status !== "success") return null;
const latest = attempts.reduce((value, item) =>
!value || item.attempt_number > value.attempt_number ? item : value, null);
if (!latest || latest.status !== "success") return null;
const stored = results.find((item) => item.task_id === task.id &&
item.attempt_id === latest.id && item.attempt_number === latest.attempt_number);
return Array.isArray(stored?.validation?.domains) ? stored.validation : null;
}
export function latestDeploymentReport(task, attempts, results, reports = []) {
const latest = Math.max(0, ...attempts.map((item) => item.attempt_number || 0));
if (taskIsPending(task) || latest === 0) return null;
@ -31,6 +41,7 @@ export function useTaskMonitor({ fetchAdmin, tasks, deploymentResult, showTaskDe
taskDetails, taskAttempts, taskDetailsLoading, taskDetailsError, refreshResources }) {
const taskQueryError = ref("");
const taskDeploymentReport = ref(null);
const taskValidationResult = ref(null);
let selectedID = "";
let stopped = false;
let timer;
@ -53,11 +64,13 @@ export function useTaskMonitor({ fetchAdmin, tasks, deploymentResult, showTaskDe
const path = `/api/v1/tasks/${encodeURIComponent(id)}`;
const task = await readJSON(path, signal);
if (task.id !== id || !task.status) throw new Error("任务状态响应不完整,将自动重试");
const [attempts, results] = await Promise.all([
const [attempts, results, validations] = await Promise.all([
readJSON(`${path}/attempts`, signal),
readJSON(`${path}/deployment-results`, signal),
task.task_type === "certificate_validate"
? readJSON(`${path}/validation-results`, signal) : Promise.resolve({ items: [] }),
]);
if (!Array.isArray(attempts.items) || !Array.isArray(results.items)) {
if (!Array.isArray(attempts.items) || !Array.isArray(results.items) || !Array.isArray(validations.items)) {
throw new Error("任务执行记录响应不完整,将自动重试");
}
const latestAttempt = attempts.items.reduce((latest, item) =>
@ -72,6 +85,7 @@ export function useTaskMonitor({ fetchAdmin, tasks, deploymentResult, showTaskDe
task,
attempts: attempts.items,
report: latestDeploymentReport(task, attempts.items, results.items, results.reports),
validation: latestValidationResult(task, attempts.items, validations.items),
};
}
@ -100,6 +114,7 @@ export function useTaskMonitor({ fetchAdmin, tasks, deploymentResult, showTaskDe
taskDetails.value = snapshot.task;
taskAttempts.value = snapshot.attempts;
taskDeploymentReport.value = snapshot.report;
taskValidationResult.value = snapshot.validation;
taskDetailsError.value = "";
}
if (deploymentResult.value?.taskID === id) {
@ -125,6 +140,7 @@ export function useTaskMonitor({ fetchAdmin, tasks, deploymentResult, showTaskDe
taskDetails.value = null;
taskAttempts.value = [];
taskDeploymentReport.value = null;
taskValidationResult.value = null;
}
}
if (deploymentResult.value?.taskID === id) {
@ -149,6 +165,7 @@ export function useTaskMonitor({ fetchAdmin, tasks, deploymentResult, showTaskDe
taskDetails.value = null;
taskAttempts.value = [];
taskDeploymentReport.value = null;
taskValidationResult.value = null;
if (deploymentResult.value) {
deploymentResult.value.report = null;
deploymentResult.value.certificateImported = false;
@ -179,6 +196,7 @@ export function useTaskMonitor({ fetchAdmin, tasks, deploymentResult, showTaskDe
taskDetails.value = null;
taskAttempts.value = [];
taskDeploymentReport.value = null;
taskValidationResult.value = null;
taskDetailsError.value = "";
taskDetailsLoading.value = true;
await refreshTaskStatus();
@ -192,6 +210,7 @@ export function useTaskMonitor({ fetchAdmin, tasks, deploymentResult, showTaskDe
taskDetails.value = null;
taskAttempts.value = [];
taskDeploymentReport.value = null;
taskValidationResult.value = null;
taskDetailsError.value = "";
taskDetailsLoading.value = false;
}
@ -203,5 +222,6 @@ export function useTaskMonitor({ fetchAdmin, tasks, deploymentResult, showTaskDe
controller?.abort();
});
return { taskQueryError, taskDeploymentReport, refreshTaskStatus, openTaskDetails, closeTaskDetails };
return { taskQueryError, taskDeploymentReport, taskValidationResult,
refreshTaskStatus, openTaskDetails, closeTaskDetails };
}

View File

@ -1,13 +1,119 @@
import assert from "node:assert/strict";
import test from "node:test";
import { useDashboard } from "./Dashboard.js";
import { latestDeploymentReport } from "./TaskMonitor.js";
import { latestDeploymentReport, latestValidationResult } from "./TaskMonitor.js";
import { validationDate } from "./TaskValidationResult.js";
import { deploymentSummary } from "./DeploymentFeedback.js";
import { deferred, mountComposable } from "../../test-support/mount.js";
const initialTask = { id: "async-task", domain_id: "domain-1", task_type: "certificate_deploy", status: "running" };
const target = { server_id: "server-1", server: "部署目标", status: "success", attempt_number: 1 };
test("校验只展示当前任务最近一次成功尝试,不挪用旧结果", () => {
const task = { ...initialTask, task_type: "certificate_validate", status: "success" };
const attempts = [{ id: "latest", attempt_number: 2, status: "success" }];
const validation = { domains: ["example.test"], fingerprint_sha256: "verified" };
const stored = { task_id: task.id, attempt_id: "latest", attempt_number: 2, validation };
assert.deepEqual(latestValidationResult(task, attempts, [stored]), validation);
for (const record of [
{ ...stored, task_id: "another" },
{ ...stored, attempt_id: "old" },
{ ...stored, attempt_number: 1 },
{ ...stored, validation: {} },
]) assert.equal(latestValidationResult(task, attempts, [record]), null);
for (const status of ["running", "failed", "cancelled"]) {
assert.equal(latestValidationResult({ ...task, status }, attempts, [stored]), null);
}
assert.equal(latestValidationResult(initialTask, attempts, [stored]), null);
assert.equal(latestValidationResult(task, [], [stored]), null);
assert.equal(validationDate("invalid"), "未记录");
assert.equal(validationDate(null), "未记录");
});
test("校验任务提交后读取独立详情,页面重开可恢复,撤权后清除", async (t) => {
let task = { ...initialTask, task_type: "certificate_validate", status: "queued" };
let denied = false;
let submitted = 0;
const validation = {
domains: ["example.test"], serial_number: "42", fingerprint_sha256: "verified",
not_before: "2026-01-01T00:00:00Z", not_after: "2026-12-31T00:00:00Z", chain_length: 2,
};
const { state } = await mountComposable(t, useDashboard, async (path, options = {}) => {
if (path.endsWith("/certificate/validate")) {
submitted++;
assert.ok(options.headers["Idempotency-Key"]);
return Response.json({ task, accepted: true }, { status: 202 });
}
if (path === `/api/v1/tasks/${task.id}`) return Response.json(task);
if (path.endsWith("/attempts")) return Response.json({
items: [{ id: "validation-attempt", attempt_number: 1, status: task.status }],
});
if (path.endsWith("/validation-results")) {
if (denied) return Response.json({ error: "禁止访问" }, { status: 403 });
return Response.json({ items: task.status === "success" ? [{
task_id: task.id, attempt_id: "validation-attempt", attempt_number: 1, validation,
}] : [] });
}
if (path.startsWith("/api/v1/tasks?")) return Response.json({ items: [task] });
return Response.json({ items: [] });
});
await state.validateDomain({ id: "domain-1", name: "example.test" });
assert.equal(submitted, 1);
assert.equal(state.showTaskDetails.value, true);
assert.match(state.notice.value, /任务已提交/);
assert.equal(state.taskValidationResult.value, null);
task = { ...task, status: "success" };
await state.refreshTaskStatus();
assert.deepEqual(state.taskValidationResult.value, validation);
assert.equal(state.taskDeploymentReport.value, null);
state.closeTaskDetails();
assert.equal(state.taskValidationResult.value, null);
await state.openTaskDetails(task);
assert.deepEqual(state.taskValidationResult.value, validation);
denied = true;
await state.refreshTaskStatus();
assert.equal(state.taskValidationResult.value, null);
assert.equal(state.taskDetails.value, null);
assert.match(state.taskDetailsError.value, /无权/);
assert.equal(submitted, 1);
});
test("校验详情拆分展示元数据和本地校验边界,不展示私钥路径", async () => {
const { readFile } = await import("node:fs/promises");
const source = await readFile(new URL("./TaskValidationResult.vue", import.meta.url), "utf8");
for (const label of ["覆盖域名", "序列号", "生效时间", "到期时间", "证书链长度", "SHA-256 指纹", "不代表远端部署"]) {
assert.ok(source.includes(label));
}
assert.match(source, /src="\.\/TaskValidationResult.css"/);
assert.doesNotMatch(source, /private_key_path|certificate_path|&#x/);
const { parse, compileTemplate } = await import("@vue/compiler-sfc");
const { renderToString } = await import("@vue/server-renderer");
const Vue = await import("vue");
const { descriptor, errors } = parse(source);
assert.deepEqual(errors, []);
const compiled = compileTemplate({
source: descriptor.template.content, filename: "TaskValidationResult.vue",
id: "validation-result", compilerOptions: { mode: "function" },
});
assert.deepEqual(compiled.errors, []);
const render = new Function("Vue", compiled.code)(Vue);
const app = Vue.createSSRApp({
render,
setup: () => ({ validationDate, result: {
domains: ["example.test", "*.example.test"], serial_number: "98765",
fingerprint_sha256: "AB:CD:EF", chain_length: 2,
not_before: "2026-01-01T00:00:00Z", not_after: "2026-12-31T00:00:00Z",
} }),
});
const warnings = [];
app.config.warnHandler = (message) => warnings.push(message);
const html = await renderToString(app);
assert.deepEqual(warnings, []);
assert.match(html, /example.test、\*.example.test/);
assert.match(html, /AB:CD:EF/);
assert.match(html, /98765/);
});
test("完整报告恢复站点验证与回滚事实,不被逐目标结果覆盖", () => {
const task = { ...initialTask, status: "failed" };
const attempts = [{ id: "attempt-2", attempt_number: 2, status: "failed" }];

View File

@ -0,0 +1,30 @@
.task-validation {
margin-top: 18px;
}
.task-validation dl {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 18px;
}
.task-validation .wide {
grid-column: 1 / -1;
}
.task-validation dt,
.task-validation p {
color: #64748b;
font-size: 13px;
}
.task-validation dd {
margin: 4px 0 0;
overflow-wrap: anywhere;
}
@media (max-width: 640px) {
.task-validation dl {
grid-template-columns: minmax(0, 1fr);
}
}

View File

@ -0,0 +1,5 @@
export function validationDate(value) {
if (!value) return "未记录";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? "未记录" : date.toLocaleString("zh-CN", { hour12: false });
}

View File

@ -0,0 +1,22 @@
<script setup>
import { validationDate } from "./TaskValidationResult.js";
defineProps({ result: { type: Object, required: true } });
</script>
<template>
<section class="task-validation" aria-label="本次证书校验结果">
<h3>本次证书校验结果</h3>
<dl>
<div class="wide"><dt>覆盖域名</dt><dd>{{ result.domains.join("、") || "未记录" }}</dd></div>
<div><dt>生效时间</dt><dd>{{ validationDate(result.not_before) }}</dd></div>
<div><dt>到期时间</dt><dd>{{ validationDate(result.not_after) }}</dd></div>
<div><dt>序列号</dt><dd>{{ result.serial_number || "未记录" }}</dd></div>
<div><dt>证书链长度</dt><dd>{{ result.chain_length ?? "未记录" }}</dd></div>
<div class="wide"><dt>SHA-256 指纹</dt><dd>{{ result.fingerprint_sha256 || "未记录" }}</dd></div>
</dl>
<p>此结果仅表示本次本地证书、私钥匹配及信任链校验通过,不代表远端部署或公网 HTTPS 验收通过。</p>
</section>
</template>
<style scoped src="./TaskValidationResult.css"></style>