Scert/backend/internal/httpapi/task_api_test.go

243 lines
10 KiB
Go
Raw 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 (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
"git.pcux.cn/xuans/Scert/backend/internal/database"
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
"git.pcux.cn/xuans/Scert/backend/internal/repository"
)
func newTaskAPITestServer(t *testing.T, issue func(context.Context, string) (certificate.IssueResult, deployment.Report, error)) (*Server, *repository.SQLTaskRepository, *repository.SQLAuditRepository, func()) {
t.Helper()
cfg := readyTestConfig(t.TempDir())
db, err := database.Open(context.Background(), cfg.Database)
if err != nil {
t.Fatalf("打开测试数据库失败:%v", err)
}
if err := database.Migrate(context.Background(), db.DB, cfg.Database.Driver); err != nil {
db.Close()
t.Fatalf("迁移测试数据库失败:%v", err)
}
domainRepo := repository.NewSQLDomainRepository(db.DB)
taskRepo := repository.NewSQLTaskRepository(db.DB)
auditRepo := repository.NewSQLAuditRepository(db.DB)
item, err := domain.NewDomain("example.com", domain.CertificateSingle, nil)
item.ID = "domain-1"
if err != nil {
db.Close()
t.Fatal(err)
}
if err := domainRepo.Create(context.Background(), item); err != nil {
db.Close()
t.Fatalf("创建测试域名失败:%v", err)
}
server := New(Dependencies{
Config: cfg,
Repository: domainRepo,
IssueCertificate: issue,
Tasks: taskRepo,
Audit: auditRepo,
})
return server, taskRepo, auditRepo, func() { _ = db.Close() }
}
func TestTaskAPIRecordsSuccessfulIssueAndAudit(t *testing.T) {
server, taskRepo, auditRepo, closeDB := newTaskAPITestServer(t, func(_ context.Context, id string) (certificate.IssueResult, deployment.Report, error) {
return certificate.IssueResult{DomainID: id}, deployment.Report{Status: "success"}, nil
})
defer closeDB()
response := httptest.NewRecorder()
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil))
if response.Code != http.StatusOK {
t.Fatalf("成功任务响应错误:%d %s", response.Code, response.Body.String())
}
items, err := taskRepo.ListTasks(context.Background(), "", 10)
if err != nil || len(items) != 1 || items[0].Status != "success" {
t.Fatalf("任务没有进入成功状态:%+v %v", items, err)
}
attempts, err := taskRepo.ListAttempts(context.Background(), items[0].ID)
if err != nil || len(attempts) != 1 || attempts[0].Status != "success" {
t.Fatalf("任务尝试记录错误:%+v %v", attempts, err)
}
logs, err := auditRepo.ListAuditLogs(context.Background(), 10)
if err != nil || len(logs) != 1 || logs[0].Status != "success" {
t.Fatalf("审计日志记录错误:%+v %v", logs, err)
}
list := httptest.NewRecorder()
serveAdminHTTP(server, list, httptest.NewRequest(http.MethodGet, "/api/v1/tasks?limit=10", nil))
if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), items[0].ID) {
t.Fatalf("任务列表响应错误:%d %s", list.Code, list.Body.String())
}
detail := httptest.NewRecorder()
serveAdminHTTP(server, detail, httptest.NewRequest(http.MethodGet, "/api/v1/tasks/"+items[0].ID, nil))
if detail.Code != http.StatusOK || !strings.Contains(detail.Body.String(), "success") {
t.Fatalf("任务详情响应错误:%d %s", detail.Code, detail.Body.String())
}
attemptList := httptest.NewRecorder()
serveAdminHTTP(server, attemptList, httptest.NewRequest(http.MethodGet, "/api/v1/tasks/"+items[0].ID+"/attempts", nil))
if attemptList.Code != http.StatusOK || !strings.Contains(attemptList.Body.String(), "certificate_issue") {
t.Fatalf("任务尝试接口响应错误:%d %s", attemptList.Code, attemptList.Body.String())
}
logList := httptest.NewRecorder()
serveAdminHTTP(server, logList, httptest.NewRequest(http.MethodGet, "/api/v1/audit-logs?limit=10", nil))
if logList.Code != http.StatusOK || !strings.Contains(logList.Body.String(), "certificate_issue") {
t.Fatalf("审计日志接口响应错误:%d %s", logList.Code, logList.Body.String())
}
}
func TestTaskAPIReturnsFailureAndKeepsTaskTrace(t *testing.T) {
server, taskRepo, auditRepo, closeDB := newTaskAPITestServer(t, func(context.Context, string) (certificate.IssueResult, deployment.Report, error) {
return certificate.IssueResult{}, deployment.Report{Status: "failed"}, errors.New("模拟证书申请失败")
})
defer closeDB()
response := httptest.NewRecorder()
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil))
if response.Code != http.StatusBadGateway || !strings.Contains(response.Body.String(), "模拟证书申请失败") {
t.Fatalf("失败任务响应错误:%d %s", response.Code, response.Body.String())
}
var payload struct {
Task struct {
Status string `json:"status"`
Stage string `json:"stage"`
Message string `json:"message"`
FinishedAt string `json:"finished_at"`
UpdatedAt string `json:"updated_at"`
} `json:"task"`
TaskStatus string `json:"task_status"`
FinishedAt string `json:"finished_at"`
}
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
t.Fatalf("失败任务响应不是有效 JSON:%v;响应:%s", err, response.Body.String())
}
if payload.Task.Status != "failed" || payload.Task.Stage != "certificate_issue" || payload.Task.Message != "模拟证书申请失败" {
t.Fatalf("失败任务嵌套状态不一致:%+v", payload.Task)
}
if payload.TaskStatus != "failed" || payload.FinishedAt == "" || payload.Task.FinishedAt == "" || payload.Task.UpdatedAt == "" {
t.Fatalf("失败任务顶层状态或时间字段不完整:%+v", payload)
}
items, err := taskRepo.ListTasks(context.Background(), "", 10)
if err != nil || len(items) != 1 || items[0].Status != "failed" {
t.Fatalf("失败任务状态错误:%+v %v", items, err)
}
logs, err := auditRepo.ListAuditLogs(context.Background(), 10)
if err != nil || len(logs) != 1 || logs[0].Status != "error" {
t.Fatalf("失败审计状态错误:%+v %v", logs, err)
}
}
func TestTaskAPIRejectsUnknownTask(t *testing.T) {
server, _, _, closeDB := newTaskAPITestServer(t, nil)
defer closeDB()
response := httptest.NewRecorder()
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodGet, "/api/v1/tasks/unknown", nil))
if response.Code != http.StatusNotFound {
t.Fatalf("未知任务应返回 404:%d %s", response.Code, response.Body.String())
}
}
func TestTaskAPICanCancelRunningIssue(t *testing.T) {
started := make(chan struct{})
server, taskRepo, _, closeDB := newTaskAPITestServer(t, func(ctx context.Context, id string) (certificate.IssueResult, deployment.Report, error) {
close(started)
<-ctx.Done()
return certificate.IssueResult{DomainID: id}, deployment.Report{}, ctx.Err()
})
defer closeDB()
issueResponse := httptest.NewRecorder()
issueDone := make(chan struct{})
go func() {
serveAdminHTTP(server, issueResponse, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil))
close(issueDone)
}()
select {
case <-started:
case <-time.After(2 * time.Second):
t.Fatal("证书任务没有进入执行状态")
}
items, err := taskRepo.ListTasks(context.Background(), "", 10)
if err != nil || len(items) != 1 {
t.Fatalf("读取运行中任务失败:%+v %v", items, err)
}
cancelResponse := httptest.NewRecorder()
serveAdminHTTP(server, cancelResponse, httptest.NewRequest(http.MethodDelete, "/api/v1/tasks/"+items[0].ID, nil))
if cancelResponse.Code != http.StatusOK || !strings.Contains(cancelResponse.Body.String(), "cancelled") {
t.Fatalf("取消任务响应错误:%d %s", cancelResponse.Code, cancelResponse.Body.String())
}
select {
case <-issueDone:
case <-time.After(2 * time.Second):
t.Fatal("取消后证书任务没有结束")
}
if issueResponse.Code != http.StatusConflict {
t.Fatalf("取消任务的申请请求应返回 409:%d %s", issueResponse.Code, issueResponse.Body.String())
}
item, err := taskRepo.GetTask(context.Background(), items[0].ID)
if err != nil || item.Status != "cancelled" {
t.Fatalf("任务取消状态没有持久化:%+v %v", item, err)
}
}
func TestTaskAPIReplaysIdempotentIssue(t *testing.T) {
calls := 0
server, taskRepo, _, closeDB := newTaskAPITestServer(t, func(_ context.Context, id string) (certificate.IssueResult, deployment.Report, error) {
calls++
return certificate.IssueResult{DomainID: id}, deployment.Report{Status: "success"}, nil
})
defer closeDB()
firstRequest := httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil)
firstRequest.Header.Set("Idempotency-Key", "issue-key-20260830")
firstResponse := httptest.NewRecorder()
serveAdminHTTP(server, firstResponse, firstRequest)
if firstResponse.Code != http.StatusOK {
t.Fatalf("首次幂等请求失败:%d %s", firstResponse.Code, firstResponse.Body.String())
}
secondRequest := httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil)
secondRequest.Header.Set("Idempotency-Key", "issue-key-20260830")
secondResponse := httptest.NewRecorder()
serveAdminHTTP(server, secondResponse, secondRequest)
if secondResponse.Code != http.StatusOK || !strings.Contains(secondResponse.Body.String(), "replayed") {
t.Fatalf("重复幂等请求响应错误:%d %s", secondResponse.Code, secondResponse.Body.String())
}
if calls != 1 {
t.Fatalf("幂等请求重复执行了证书申请:%d", calls)
}
items, err := taskRepo.ListTasks(context.Background(), "", 10)
if err != nil || len(items) != 1 || items[0].IdempotencyKey != "issue-key-20260830" {
t.Fatalf("幂等键没有正确保存:%+v %v", items, err)
}
}
func TestTaskAPIReturnsNotImplementedWhenTaskDependenciesAreMissing(t *testing.T) {
cfg := readyTestConfig(t.TempDir())
server := New(Dependencies{
Config: cfg,
Tasks: (*repository.SQLTaskRepository)(nil),
IssueCertificate: func(context.Context, string) (certificate.IssueResult, deployment.Report, error) {
return certificate.IssueResult{}, deployment.Report{}, nil
},
})
response := httptest.NewRecorder()
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil))
if response.Code != http.StatusNotImplemented || !strings.Contains(response.Body.String(), "证书任务接口尚未配置") {
t.Fatalf("任务依赖缺失时应返回 501:%d %s", response.Code, response.Body.String())
}
}