Scert/backend/internal/httpapi/task_deployment_results_test.go

196 lines
9.4 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 (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
"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 TestTaskDeploymentResultsIssueAndRead(t *testing.T) {
server, tasks, _, closeDB := newTaskAPITestServer(t, func(_ context.Context, id string) (certificate.IssueResult, deployment.Report, error) {
return certificate.IssueResult{DomainID: id}, deployment.Report{
Status: "partial",
Results: []deployment.Result{
{ServerID: "server-a", Server: "服务器甲", Status: "success", Message: "部署完成", BackupID: "backup-a"},
{ServerID: "server-b", Server: "服务器乙", Status: "failed", Message: "/etc/scert/private/key.pem: permission denied"},
},
}, 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.StatusAccepted {
t.Fatalf("任务提交响应错误:%d %s", response.Code, response.Body.String())
}
waitTaskWorkersForTest(t, server)
items, err := tasks.ListTasks(t.Context(), "", 10)
if err != nil || len(items) != 1 || items[0].Status != "partial_success" {
t.Fatalf("部分成功任务记录错误:%+v %v", items, err)
}
attempts, err := tasks.ListAttempts(t.Context(), items[0].ID)
if err != nil || len(attempts) != 1 {
t.Fatalf("任务尝试记录错误:%+v %v", attempts, err)
}
// 模拟应用层持久化的原始错误,确保查询入口也独立脱敏。
if err := tasks.RecordTaskDeploymentResults(t.Context(), items[0].ID, attempts[0].ID, 1, []repository.TaskDeploymentResult{
{ResultIndex: 0, ServerID: "server-a", Server: "服务器甲", Status: "success", Message: "部署完成", BackupID: "backup-a"},
{ResultIndex: 1, ServerID: "server-b", Server: "服务器乙", Status: "failed", Message: "/etc/scert/private/key.pem: permission denied"},
}); err != nil {
t.Fatal(err)
}
response = httptest.NewRecorder()
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodGet, "/api/v1/tasks/"+items[0].ID+"/deployment-results", nil))
if response.Code != http.StatusOK {
t.Fatalf("部署结果读取失败:%d %s", response.Code, response.Body.String())
}
var payload struct {
Items []repository.TaskDeploymentResult `json:"items"`
}
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if len(payload.Items) != 2 {
t.Fatalf("服务器结果数量错误:%+v", payload.Items)
}
for _, item := range payload.Items {
if item.TaskID != items[0].ID || item.AttemptID != attempts[0].ID || item.AttemptNumber != 1 || item.CreatedAt.IsZero() {
t.Fatalf("部署结果任务关联缺失:%+v", item)
}
}
if payload.Items[0].ServerID != "server-a" || payload.Items[0].BackupID != "backup-a" || payload.Items[0].Status != "success" {
t.Fatalf("成功结果错误:%+v", payload.Items[0])
}
if payload.Items[1].ServerID != "server-b" || payload.Items[1].Status != "failed" || payload.Items[1].Message != "证书部署失败" {
t.Fatalf("失败结果未脱敏:%+v", payload.Items[1])
}
for _, secret := range []string{"/etc/scert/private/key.pem", "permission denied"} {
if strings.Contains(response.Body.String(), secret) {
t.Fatalf("部署结果泄露底层错误:%s", response.Body.String())
}
}
}
func TestTaskDeploymentResultsKeepAllowlistedLockDiagnosis(t *testing.T) {
raw := deployment.Result{ServerID: "server-a", Status: "failed", FailureCode: deployment.FailureLock, Message: "secret-path remote-command-output"}
server, tasks, _, closeDB := newTaskAPITestServer(t, func(_ context.Context, id string) (certificate.IssueResult, deployment.Report, error) {
return certificate.IssueResult{DomainID: id}, deployment.Report{Status: "failed", Total: 1, Failed: 1, Results: []deployment.Result{raw}}, 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.StatusAccepted || strings.Contains(response.Body.String(), "secret-path") {
t.Fatalf("任务提交失败或泄露诊断原文:%d %s", response.Code, response.Body.String())
}
waitTaskWorkersForTest(t, server)
items, err := tasks.ListTasks(t.Context(), "", 10)
if err != nil || len(items) != 1 {
t.Fatalf("任务未保存:%+v %v", items, err)
}
response = httptest.NewRecorder()
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodGet, "/api/v1/tasks/"+items[0].ID+"/deployment-results", nil))
var payload struct {
Items []repository.TaskDeploymentResult `json:"items"`
}
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if response.Code != http.StatusOK || len(payload.Items) != 1 || payload.Items[0].Message != deployment.PublicResult(raw).Message {
t.Fatalf("任务查询丢失固定锁诊断:%d %s", response.Code, response.Body.String())
}
}
func TestTaskDeploymentResultsHTTPSFailurePreservesTransferEvidence(t *testing.T) {
server, tasks, _, closeDB := newTaskAPITestServer(t, func(_ context.Context, id string) (certificate.IssueResult, deployment.Report, error) {
return certificate.IssueResult{DomainID: id}, deployment.Report{
Total: 1, Success: 1, Status: "failed", VerificationStatus: "failed",
Results: []deployment.Result{{ServerID: "server-a", Status: "success", Message: "文件已推送并重载"}},
}, errors.New("HTTPS 校验失败:/secret-path 不应返回客户端")
})
defer closeDB()
response := httptest.NewRecorder()
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil))
if response.Code != http.StatusAccepted {
t.Fatalf("任务提交失败:%d %s", response.Code, response.Body.String())
}
waitTaskWorkersForTest(t, server)
location := response.Header().Get("Location")
response = httptest.NewRecorder()
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodGet, location, nil))
var task repository.Task
if err := json.Unmarshal(response.Body.Bytes(), &task); err != nil {
t.Fatal(err)
}
if response.Code != http.StatusOK || task.Status != "failed" || strings.Contains(response.Body.String(), "/secret-path") {
t.Fatalf("状态查询未保留整体失败或泄露底层错误:%d %s", response.Code, response.Body.String())
}
items, err := tasks.ListTasks(t.Context(), "", 10)
if err != nil || len(items) != 1 || items[0].Status != "failed" {
t.Fatalf("任务持久化状态错误:%+v,%v", items, err)
}
results, err := tasks.ListTaskDeploymentResults(t.Context(), items[0].ID)
if err != nil || len(results) != 1 || results[0].Status != "success" {
t.Fatalf("持久化单台推送结果错误:%+v,%v", results, err)
}
}
func TestTaskDeploymentResultsVisibility(t *testing.T) {
server, tasks, _, closeDB := newTaskAPITestServer(t, nil)
defer closeDB()
task := repository.Task{ID: "results-task", DomainID: "domain-1", TaskType: "certificate_issue", Status: "success", Stage: "deploy"}
if err := tasks.CreateTask(t.Context(), task); err != nil {
t.Fatal(err)
}
if err := tasks.CreateAttempt(t.Context(), repository.TaskAttempt{ID: "results-attempt", TaskID: task.ID, AttemptNumber: 1, Status: "success", Stage: "deploy"}); err != nil {
t.Fatal(err)
}
if err := tasks.RecordTaskDeploymentResults(t.Context(), task.ID, "results-attempt", 1, []repository.TaskDeploymentResult{{ServerID: "private-server", Status: "success"}}); err != nil {
t.Fatal(err)
}
adminCookie := httpUserSessionCookie(server, "test-admin", repository.UserRoleAdmin)
outsiderCookie := httpUserSessionCookie(server, "outsider", repository.UserRoleUser)
memberCookie := httpUserSessionCookie(server, "reader", repository.UserRoleUser)
if err := server.projectMembers.GrantProjectRole(t.Context(), "test-admin", domain.DefaultProjectID, "reader", domain.ProjectRoleReadOnly); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name string
cookie *http.Cookie
status int
}{
{"admin", adminCookie, http.StatusOK},
{"member", memberCookie, http.StatusOK},
{"outsider", outsiderCookie, http.StatusForbidden},
{"anonymous", nil, http.StatusUnauthorized},
} {
t.Run(tc.name, func(t *testing.T) {
response := serveUserRequest(server, tc.cookie, http.MethodGet, "/api/v1/tasks/"+task.ID+"/deployment-results", "")
if response.Code != tc.status {
t.Fatalf("部署结果权限响应错误:%d %s", response.Code, response.Body.String())
}
if tc.status != http.StatusOK && strings.Contains(response.Body.String(), "private-server") {
t.Fatal("未授权响应泄露服务器信息")
}
})
}
if err := server.projectMembers.RevokeProjectRole(t.Context(), "test-admin", domain.DefaultProjectID, "reader", domain.ProjectRoleReadOnly); err != nil {
t.Fatal(err)
}
response := serveUserRequest(server, memberCookie, http.MethodGet, "/api/v1/tasks/"+task.ID+"/deployment-results", "")
if response.Code != http.StatusForbidden {
t.Fatalf("撤权后旧会话仍可读取部署结果:%d %s", response.Code, response.Body.String())
}
visible, err := tasks.ListVisibleTaskDeploymentResults(t.Context(), "reader", task.ID)
if err != nil || len(visible) != 0 {
t.Fatalf("仓储读取未实时隔离撤权成员:%+v %v", visible, err)
}
}