Scert/backend/internal/httpapi/certificate_import_async_test.go
xuans 857fb0e632 修复后端全量测试环境兼容问题
修正 Windows 本地回环 SFTP 测试的远程绝对路径映射,并避免在不支持 POSIX 权限位的环境中误判上传临时文件权限。后端全量测试和 go vet 已通过。
2026-10-03 02:48:07 +08:00

210 lines
7.7 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

package httpapi
import (
"bytes"
"context"
"encoding/json"
"errors"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"sync/atomic"
"testing"
"github.com/gin-gonic/gin"
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
)
func importUploadRequest(t *testing.T, key string) *http.Request {
t.Helper()
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
for _, name := range []string{"certificate", "private_key"} {
part, err := writer.CreateFormFile(name, name+".pem")
if err != nil {
t.Fatal(err)
}
if _, err := part.Write([]byte(name)); err != nil {
t.Fatal(err)
}
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/certificate/import", body)
request.Header.Set("Content-Type", writer.FormDataContentType())
request.Header.Set("Idempotency-Key", key)
return request
}
func TestAsyncImportUploadLifecycle(t *testing.T) {
for _, scenario := range []string{"success", "failure", "saved_failure", "partial", "panic", "cancel", "shutdown", "stopped", "full", "invalid_key", "replay"} {
t.Run(scenario, func(t *testing.T) {
temporary := t.TempDir()
for _, name := range []string{"TMPDIR", "TMP", "TEMP"} {
t.Setenv(name, temporary)
}
var calls atomic.Int32
server, tasks, _, cleanup := newTaskAPITestServer(t, nil)
defer cleanup()
server.importCertificateTask = func(_ context.Context, request certificate.ImportRequest) (certificate.IssueResult, deployment.Report, error) {
calls.Add(1)
for path, expected := range map[string]string{request.Certificate: "certificate", request.PrivateKey: "private_key"} {
data, err := os.ReadFile(path)
if err != nil || string(data) != expected {
t.Errorf("上传文件被提前回收或内容变化:%v", err)
}
info, err := os.Stat(path)
if err != nil || (runtime.GOOS != "windows" && info.Mode().Perm() != 0600) {
t.Errorf("上传文件权限错误:%v", err)
}
}
if scenario == "panic" {
panic("private-import-error")
}
if scenario == "failure" {
return certificate.IssueResult{}, deployment.Report{}, errors.New("private-import-error")
}
result := certificate.IssueResult{DomainID: request.DomainID, Saved: certificate.SavedResource{
CertificatePath: "/private/cert.pem", FullChainPath: "/private/fullchain.pem", PrivateKeyPath: "/private/key.pem",
}}
if scenario == "saved_failure" || scenario == "partial" {
status := "failed"
if scenario == "partial" {
status = "partial"
}
return result, deployment.Report{Status: status}, errors.New("private-deploy-error")
}
return result, deployment.Report{Status: "success"}, nil
}
server.asyncTasks = make(map[string]bool)
server.taskSlots = make(chan struct{}, 1)
server.taskSlots <- struct{}{}
key := "import-lifecycle"
switch scenario {
case "stopped":
server.stopTaskWorkers()
case "full":
for index := range 32 {
server.asyncTasks[string(rune('a'+index))] = true
}
case "invalid_key":
key = strings.Repeat("x", 300)
}
response := httptest.NewRecorder()
request := importUploadRequest(t, key)
requestCtx, cancelRequest := context.WithCancel(request.Context())
serveAdminHTTP(server, response, request.WithContext(requestCtx))
cancelRequest()
files, err := filepath.Glob(filepath.Join(temporary, "scert-upload-*"))
if err != nil {
t.Fatal(err)
}
if scenario == "stopped" || scenario == "full" || scenario == "invalid_key" {
want := http.StatusServiceUnavailable
if scenario == "invalid_key" {
want = http.StatusBadRequest
}
if response.Code != want || len(files) != 0 || calls.Load() != 0 {
t.Fatalf("拒绝提交仍保留上传文件或执行导入:%d %v %d", response.Code, files, calls.Load())
}
return
}
task := submittedTask(t, response)
if task.TaskType != "certificate_import" || len(files) != 2 || calls.Load() != 0 {
t.Fatalf("排队状态或上传文件所有权错误:%+v %v %d", task, files, calls.Load())
}
if scenario == "replay" {
replay := httptest.NewRecorder()
serveAdminHTTP(server, replay, importUploadRequest(t, key))
remaining, _ := filepath.Glob(filepath.Join(temporary, "scert-upload-*"))
if replay.Code != http.StatusConflict || len(remaining) != 2 {
t.Fatalf("重复提交未回收本次上传文件:%d %v", replay.Code, remaining)
}
}
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 %s", cancelled.Code, cancelled.Body.String())
}
case "shutdown":
server.stopTaskWorkers()
default:
<-server.taskSlots
}
waitTaskWorkersForTest(t, server)
wantStatus := "success"
wantCalls := int32(1)
switch scenario {
case "failure", "saved_failure", "panic":
wantStatus = "failed"
case "partial":
wantStatus = "partial_success"
case "cancel", "shutdown":
wantStatus, wantCalls = "cancelled", 0
}
stored, err := tasks.GetTask(t.Context(), task.ID)
if err != nil || stored.Status != wantStatus || calls.Load() != wantCalls {
t.Fatalf("导入终态错误:%+v %v,执行次数 %d", stored, err, calls.Load())
}
for _, path := range files {
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("上传文件未回收:%v", err)
}
}
if scenario == "saved_failure" || scenario == "partial" || scenario == "success" || scenario == "failure" {
reportResponse := httptest.NewRecorder()
serveAdminHTTP(server, reportResponse, httptest.NewRequest(http.MethodGet, "/api/v1/tasks/"+task.ID+"/deployment-results", nil))
var payload struct {
Reports []struct {
Report deployment.Report `json:"report"`
} `json:"reports"`
}
if err := json.Unmarshal(reportResponse.Body.Bytes(), &payload); err != nil || len(payload.Reports) != 1 {
t.Fatalf("完整导入结果未持久化:%s %v", reportResponse.Body.String(), err)
}
if payload.Reports[0].Report.CertificateImported != (scenario != "failure") {
t.Fatalf("导入是否保存的状态丢失:%+v", payload)
}
if strings.Contains(reportResponse.Body.String(), "private-") || strings.Contains(reportResponse.Body.String(), "/private/") || strings.Contains(stored.Message, "private-") {
t.Fatal("公开结果泄露内部错误或证书路径")
}
if scenario == "saved_failure" || scenario == "partial" {
if !strings.Contains(stored.Message, "无需重新导入") {
t.Fatalf("已保存证书的失败结果不明确:%s", stored.Message)
}
}
}
if scenario == "failure" {
retry := httptest.NewRecorder()
serveAdminHTTP(server, retry, httptest.NewRequest(http.MethodPost, "/api/v1/tasks/"+task.ID+"/retry", nil))
if retry.Code != http.StatusConflict || calls.Load() != 1 {
t.Fatal("不得重放已回收上传文件的导入任务")
}
}
})
}
}
func TestAsyncImportRejectedSubmissionCleansFiles(t *testing.T) {
server, _, _, cleanup := newTaskAPITestServer(t, nil)
defer cleanup()
var cleaned atomic.Int32
server.tasks = nil
response := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(response)
ctx.Request = httptest.NewRequest(http.MethodPost, "/", nil)
server.submitPreparedDomainTask(ctx, "certificate_import", &preparedCertificateTask{cleanup: func() { cleaned.Add(1) }})
if response.Code != http.StatusNotImplemented || cleaned.Load() != 1 {
t.Fatal("缺失任务仓储时必须回收上传文件")
}
}