From 3aa1d50251cca3298070d8232943900fed925a34 Mon Sep 17 00:00:00 2001 From: xuans Date: Sat, 3 Oct 2026 02:17:25 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E8=AF=81=E4=B9=A6=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E4=BB=BB=E5=8A=A1=E6=9D=83=E9=99=90=E4=B8=8E=E4=B8=93?= =?UTF-8?q?=E9=A1=B9=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../httpapi/certificate_restore_async_test.go | 156 ++++++++++++++++++ backend/internal/httpapi/task_api.go | 2 +- .../httpapi/task_execution_authorization.go | 2 + 3 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 backend/internal/httpapi/certificate_restore_async_test.go diff --git a/backend/internal/httpapi/certificate_restore_async_test.go b/backend/internal/httpapi/certificate_restore_async_test.go new file mode 100644 index 0000000..0c8a130 --- /dev/null +++ b/backend/internal/httpapi/certificate_restore_async_test.go @@ -0,0 +1,156 @@ +package httpapi + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/certificate" +) + +func restoreRequest(key string) *http.Request { + request := httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/certificate/versions/history-1/restore", nil) + request.Header.Set("Idempotency-Key", key) + return request +} + +func TestAsyncRestoreSubmitsPersistedTaskWithoutNestedWorkflow(t *testing.T) { + started := make(chan struct{}) + release := make(chan struct{}) + var calls atomic.Int32 + var gotDomain, gotVersion string + server, tasks, _, cleanup := newTaskAPITestServer(t, nil) + defer cleanup() + server.restoreVersion = func(context.Context, string, string) (certificate.IssueResult, error) { + t.Fatal("异步恢复不得调用兼容同步工作流") + return certificate.IssueResult{}, nil + } + server.restoreVersionTask = func(ctx context.Context, domainID, version string) (certificate.IssueResult, error) { + calls.Add(1) + gotDomain, gotVersion = domainID, version + close(started) + select { + case <-release: + return certificate.IssueResult{DomainID: domainID}, nil + case <-ctx.Done(): + return certificate.IssueResult{}, ctx.Err() + } + } + + response := httptest.NewRecorder() + serveAdminHTTP(server, response, restoreRequest("restore-async-1")) + task := submittedTask(t, response) + if task.TaskType != "certificate_restore" { + t.Fatalf("恢复任务类型错误:%+v", task) + } + if response.Header().Get("Location") != "/api/v1/tasks/"+task.ID { + t.Fatalf("恢复任务查询地址错误:%s", response.Header().Get("Location")) + } + select { + case <-started: + case <-time.After(2 * time.Second): + t.Fatal("恢复后台任务未开始执行") + } + close(release) + waitTaskWorkersForTest(t, server) + + stored, err := tasks.GetTask(t.Context(), task.ID) + if err != nil || stored.Status != "success" { + t.Fatalf("恢复任务未成功收敛:%+v %v", stored, err) + } + if gotDomain != "domain-1" || gotVersion != "history-1" || calls.Load() != 1 { + t.Fatalf("恢复参数或执行次数错误:domain=%q version=%q calls=%d", gotDomain, gotVersion, calls.Load()) + } + if !strings.Contains(stored.Message, "本地证书历史版本恢复完成") || !strings.Contains(stored.Message, "远端服务器部署完成") { + t.Fatalf("恢复任务状态文案未说明本地与远端边界:%s", stored.Message) + } +} + +func TestAsyncRestoreIdempotencyAndActiveTaskConflict(t *testing.T) { + release := make(chan struct{}) + var calls atomic.Int32 + server, tasks, _, cleanup := newTaskAPITestServer(t, nil) + defer cleanup() + server.restoreVersionTask = func(ctx context.Context, domainID, _ string) (certificate.IssueResult, error) { + calls.Add(1) + select { + case <-release: + return certificate.IssueResult{DomainID: domainID}, nil + case <-ctx.Done(): + return certificate.IssueResult{}, ctx.Err() + } + } + + first := httptest.NewRecorder() + serveAdminHTTP(server, first, restoreRequest("restore-duplicate")) + task := submittedTask(t, first) + replay := httptest.NewRecorder() + serveAdminHTTP(server, replay, restoreRequest("restore-duplicate")) + if replay.Code != http.StatusConflict || !strings.Contains(replay.Body.String(), task.ID) { + t.Fatalf("活动恢复任务幂等重放未被拒绝:%d %s", replay.Code, replay.Body.String()) + } + conflict := httptest.NewRecorder() + serveAdminHTTP(server, conflict, restoreRequest("restore-other")) + if conflict.Code != http.StatusConflict { + t.Fatalf("同域名活动恢复任务未被拒绝:%d %s", conflict.Code, conflict.Body.String()) + } + close(release) + waitTaskWorkersForTest(t, server) + stored, err := tasks.GetTask(t.Context(), task.ID) + if err != nil || stored.Status != "success" || calls.Load() != 1 { + t.Fatalf("幂等或冲突检查影响恢复任务:%+v %v calls=%d", stored, err, calls.Load()) + } +} + +func TestAsyncRestoreFailureDoesNotExposePrivatePath(t *testing.T) { + server, tasks, _, cleanup := newTaskAPITestServer(t, nil) + defer cleanup() + server.restoreVersionTask = func(context.Context, string, string) (certificate.IssueResult, error) { + return certificate.IssueResult{}, errors.New("读取私钥失败:/etc/scert/private/live/key.pem") + } + response := httptest.NewRecorder() + serveAdminHTTP(server, response, restoreRequest("restore-failure")) + task := submittedTask(t, response) + waitTaskWorkersForTest(t, server) + stored, err := tasks.GetTask(t.Context(), task.ID) + if err != nil || stored.Status != "failed" { + t.Fatalf("恢复失败任务未收敛:%+v %v", stored, err) + } + if strings.Contains(stored.Message, "/etc/scert/private") || strings.Contains(response.Body.String(), "/etc/scert/private") { + t.Fatal("恢复失败结果泄露私钥路径") + } +} + +func TestAsyncRestoreCancellationStopsWorkflow(t *testing.T) { + started := make(chan struct{}) + server, tasks, _, cleanup := newTaskAPITestServer(t, nil) + defer cleanup() + server.restoreVersionTask = func(ctx context.Context, domainID, _ string) (certificate.IssueResult, error) { + close(started) + <-ctx.Done() + return certificate.IssueResult{DomainID: domainID}, ctx.Err() + } + response := httptest.NewRecorder() + serveAdminHTTP(server, response, restoreRequest("restore-cancel")) + task := submittedTask(t, response) + select { + case <-started: + case <-time.After(2 * time.Second): + t.Fatal("恢复任务未开始执行") + } + cancel := httptest.NewRecorder() + serveAdminHTTP(server, cancel, httptest.NewRequest(http.MethodDelete, "/api/v1/tasks/"+task.ID, nil)) + if cancel.Code != http.StatusAccepted { + t.Fatalf("取消恢复任务失败:%d %s", cancel.Code, cancel.Body.String()) + } + waitTaskWorkersForTest(t, server) + stored, err := tasks.GetTask(t.Context(), task.ID) + if err != nil || stored.Status != "cancelled" { + t.Fatalf("恢复任务取消后状态错误:%+v %v", stored, err) + } +} diff --git a/backend/internal/httpapi/task_api.go b/backend/internal/httpapi/task_api.go index 7a83b1b..dbcc932 100644 --- a/backend/internal/httpapi/task_api.go +++ b/backend/internal/httpapi/task_api.go @@ -222,7 +222,7 @@ type certificateTaskResult struct { 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) { if task.TaskType == "certificate_restore" { runningMessage = "\u6b63\u5728\u6062\u590d\u672c\u5730\u8bc1\u4e66\u5386\u53f2\u7248\u672c" - successMessage = "\u672c\u5730\u8bc1\u4e66\u5386\u53f2\u7248\u672c\u6062\u590d\u5b8c\u6210\uff0c\u5c1a\u672a\u4ee3\u8868\u8fdc\u7aef\u670d\u52a1\u5668\u90e8\u7f6e\u5b8c\u6210" + successMessage = "\u672c\u5730\u8bc1\u4e66\u5386\u53f2\u7248\u672c\u6062\u590d\u5b8c\u6210\uff0c\u5c1a\u672a\u4ee3\u8868\u8fdc\u7aef\u670d\u52a1\u5668\u90e8\u7f72\u5b8c\u6210" } executionCtx := deployment.WithTransactionContext(ctx, deployment.TransactionContext{ TaskID: task.ID, AttemptID: attempt.ID, AttemptNumber: attempt.AttemptNumber, diff --git a/backend/internal/httpapi/task_execution_authorization.go b/backend/internal/httpapi/task_execution_authorization.go index 960c3e7..ffcba47 100644 --- a/backend/internal/httpapi/task_execution_authorization.go +++ b/backend/internal/httpapi/task_execution_authorization.go @@ -26,6 +26,8 @@ func (s *Server) authorizeCertificateTaskExecution(ctx context.Context, task rep case "certificate_issue": permissions = append(permissions, domain.PermissionCertificateManage, domain.PermissionDNSCredentialUse) case "certificate_deploy": + case "certificate_restore": + permissions = append(permissions, domain.PermissionPrivateKeyRead) case "certificate_import": permissions = append(permissions, domain.PermissionCertificateManage) case "certificate_validate":