diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index 8a03579..cb0baaf 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -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) }, diff --git a/backend/internal/database/migrate.go b/backend/internal/database/migrate.go index 877b299..cdeaee1 100644 --- a/backend/internal/database/migrate.go +++ b/backend/internal/database/migrate.go @@ -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" } diff --git a/backend/internal/database/migrations/mysql/0021_task_validation_results.sql b/backend/internal/database/migrations/mysql/0021_task_validation_results.sql new file mode 100644 index 0000000..64ab2cb --- /dev/null +++ b/backend/internal/database/migrations/mysql/0021_task_validation_results.sql @@ -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) +); diff --git a/backend/internal/database/migrations/postgres/0021_task_validation_results.sql b/backend/internal/database/migrations/postgres/0021_task_validation_results.sql new file mode 100644 index 0000000..a282f01 --- /dev/null +++ b/backend/internal/database/migrations/postgres/0021_task_validation_results.sql @@ -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) +); diff --git a/backend/internal/database/migrations/sqlite/0021_task_validation_results.sql b/backend/internal/database/migrations/sqlite/0021_task_validation_results.sql new file mode 100644 index 0000000..ce0b924 --- /dev/null +++ b/backend/internal/database/migrations/sqlite/0021_task_validation_results.sql @@ -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) +); diff --git a/backend/internal/database/mysql_deployment_migration_test.go b/backend/internal/database/mysql_deployment_migration_test.go index d8c0bc3..506f964 100644 --- a/backend/internal/database/mysql_deployment_migration_test.go +++ b/backend/internal/database/mysql_deployment_migration_test.go @@ -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 { diff --git a/backend/internal/database/mysql_task_validation_migration_test.go b/backend/internal/database/mysql_task_validation_migration_test.go new file mode 100644 index 0000000..3d9555c --- /dev/null +++ b/backend/internal/database/mysql_task_validation_migration_test.go @@ -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) + } + }) + } +} diff --git a/backend/internal/httpapi/certificate_api.go b/backend/internal/httpapi/certificate_api.go index 7b09e4a..8952e47 100644 --- a/backend/internal/httpapi/certificate_api.go +++ b/backend/internal/httpapi/certificate_api.go @@ -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 } diff --git a/backend/internal/httpapi/certificate_validation_async_test.go b/backend/internal/httpapi/certificate_validation_async_test.go new file mode 100644 index 0000000..8ec691a --- /dev/null +++ b/backend/internal/httpapi/certificate_validation_async_test.go @@ -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() + } +} diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go index cd9df8d..7923b48 100644 --- a/backend/internal/httpapi/server.go +++ b/backend/internal/httpapi/server.go @@ -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) diff --git a/backend/internal/httpapi/task_api.go b/backend/internal/httpapi/task_api.go index 583fc28..7f4610d 100644 --- a/backend/internal/httpapi/task_api.go +++ b/backend/internal/httpapi/task_api.go @@ -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) } } diff --git a/backend/internal/httpapi/task_execution_authorization.go b/backend/internal/httpapi/task_execution_authorization.go index 98894ce..960c3e7 100644 --- a/backend/internal/httpapi/task_execution_authorization.go +++ b/backend/internal/httpapi/task_execution_authorization.go @@ -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 } diff --git a/backend/internal/httpapi/task_execution_authorization_test.go b/backend/internal/httpapi/task_execution_authorization_test.go index 9adfc7f..c142abd 100644 --- a/backend/internal/httpapi/task_execution_authorization_test.go +++ b/backend/internal/httpapi/task_execution_authorization_test.go @@ -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" } diff --git a/backend/internal/httpapi/task_executor.go b/backend/internal/httpapi/task_executor.go index 2a212d5..27ebb72 100644 --- a/backend/internal/httpapi/task_executor.go +++ b/backend/internal/httpapi/task_executor.go @@ -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 } diff --git a/backend/internal/httpapi/task_validation.go b/backend/internal/httpapi/task_validation.go new file mode 100644 index 0000000..cb0dd68 --- /dev/null +++ b/backend/internal/httpapi/task_validation.go @@ -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}) +} diff --git a/backend/internal/repository/task_completion.go b/backend/internal/repository/task_completion.go index 0d51635..4385ba9 100644 --- a/backend/internal/repository/task_completion.go +++ b/backend/internal/repository/task_completion.go @@ -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 diff --git a/backend/internal/repository/task_validation.go b/backend/internal/repository/task_validation.go new file mode 100644 index 0000000..b387135 --- /dev/null +++ b/backend/internal/repository/task_validation.go @@ -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 +} diff --git a/backend/internal/repository/task_validation_test.go b/backend/internal/repository/task_validation_test.go new file mode 100644 index 0000000..53626de --- /dev/null +++ b/backend/internal/repository/task_validation_test.go @@ -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) + } + } + }) + } +} diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index 81742b5..5d50539 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -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 基线,无额外待提交文件。 diff --git a/frontend/src/views/Dashboard/Dashboard.js b/frontend/src/views/Dashboard/Dashboard.js index f930fbc..389b371 100644 --- a/frontend/src/views/Dashboard/Dashboard.js +++ b/frontend/src/views/Dashboard/Dashboard.js @@ -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") { diff --git a/frontend/src/views/Dashboard/Dashboard.test.js b/frontend/src/views/Dashboard/Dashboard.test.js index e18231a..cb26ac4 100644 --- a/frontend/src/views/Dashboard/Dashboard.test.js +++ b/frontend/src/views/Dashboard/Dashboard.test.js @@ -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); diff --git a/frontend/src/views/Dashboard/Dashboard.vue b/frontend/src/views/Dashboard/Dashboard.vue index 0a7a705..30811f8 100644 --- a/frontend/src/views/Dashboard/Dashboard.vue +++ b/frontend/src/views/Dashboard/Dashboard.vue @@ -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

{{ taskDetails.message || "无附加信息" }}

+ +

+ 该任务没有留存校验详情,不据此推断当前证书仍然有效。 +

暂无执行尝试记录
diff --git a/frontend/src/views/Dashboard/TaskMonitor.js b/frontend/src/views/Dashboard/TaskMonitor.js index 03d4dde..f58a85a 100644 --- a/frontend/src/views/Dashboard/TaskMonitor.js +++ b/frontend/src/views/Dashboard/TaskMonitor.js @@ -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 }; } diff --git a/frontend/src/views/Dashboard/TaskMonitor.test.js b/frontend/src/views/Dashboard/TaskMonitor.test.js index c419653..d3ae797 100644 --- a/frontend/src/views/Dashboard/TaskMonitor.test.js +++ b/frontend/src/views/Dashboard/TaskMonitor.test.js @@ -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" }]; diff --git a/frontend/src/views/Dashboard/TaskValidationResult.css b/frontend/src/views/Dashboard/TaskValidationResult.css new file mode 100644 index 0000000..4696a83 --- /dev/null +++ b/frontend/src/views/Dashboard/TaskValidationResult.css @@ -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); + } +} diff --git a/frontend/src/views/Dashboard/TaskValidationResult.js b/frontend/src/views/Dashboard/TaskValidationResult.js new file mode 100644 index 0000000..de77525 --- /dev/null +++ b/frontend/src/views/Dashboard/TaskValidationResult.js @@ -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 }); +} diff --git a/frontend/src/views/Dashboard/TaskValidationResult.vue b/frontend/src/views/Dashboard/TaskValidationResult.vue new file mode 100644 index 0000000..db4535a --- /dev/null +++ b/frontend/src/views/Dashboard/TaskValidationResult.vue @@ -0,0 +1,22 @@ + + + + +