166 lines
7.8 KiB
Go
166 lines
7.8 KiB
Go
package httpapi
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/http"
|
||
"strings"
|
||
"testing"
|
||
|
||
"git.pcux.cn/xuans/Scert/backend/internal/domain"
|
||
"git.pcux.cn/xuans/Scert/backend/internal/repository"
|
||
)
|
||
|
||
func TestProjectLifecycleAPIFlow(t *testing.T) {
|
||
f := newProjectMemberAPIFixture(t)
|
||
owner := f.cookies["primary-super-admin"]
|
||
created := serveUserRequest(f.server, owner, http.MethodPost, "/api/v1/projects", `{"name":"新项目","description":"项目说明"}`)
|
||
var item domain.Project
|
||
if created.Code != http.StatusCreated || json.Unmarshal(created.Body.Bytes(), &item) != nil || item.ID == "" || item.Name != "新项目" {
|
||
t.Fatalf("项目创建失败:%d,%s", created.Code, created.Body.String())
|
||
}
|
||
path := "/api/v1/projects/" + item.ID
|
||
updated := serveUserRequest(f.server, owner, http.MethodPut, path, `{"name":"更新项目","description":"更新说明"}`)
|
||
if updated.Code != http.StatusOK {
|
||
t.Fatalf("项目更新失败:%d,%s", updated.Code, updated.Body.String())
|
||
}
|
||
stored, err := repository.NewSQLProjectRepository(f.db).GetProject(t.Context(), item.ID)
|
||
if err != nil || stored.Name != "更新项目" || !stored.CreatedAt.Equal(item.CreatedAt) {
|
||
t.Fatalf("项目资料或创建时间错误:%+v,%v", stored, err)
|
||
}
|
||
deleted := serveUserRequest(f.server, owner, http.MethodDelete, path, fmt.Sprintf(`{"confirmation":%q}`, item.ID))
|
||
if deleted.Code != http.StatusNoContent || deleted.Body.Len() != 0 {
|
||
t.Fatalf("项目删除失败:%d,%s", deleted.Code, deleted.Body.String())
|
||
}
|
||
for _, response := range []string{created.Header().Get("Cache-Control"), updated.Header().Get("Cache-Control"), deleted.Header().Get("Cache-Control")} {
|
||
if !strings.Contains(response, "no-store") {
|
||
t.Fatal("项目变更响应允许缓存")
|
||
}
|
||
}
|
||
log := f.lastAudit(t)
|
||
if log.Action != "project.delete" || log.Status != "success" || log.ProjectID != item.ID || log.OperatorID != "primary-super-admin" {
|
||
t.Fatalf("删除后的审计快照错误:%+v", log)
|
||
}
|
||
}
|
||
|
||
func TestProjectLifecycleAPIAuthorization(t *testing.T) {
|
||
f := newProjectMemberAPIFixture(t)
|
||
for _, tc := range []struct {
|
||
name, actor, method, project string
|
||
status int
|
||
}{
|
||
{"anonymous", "", http.MethodPost, "", 401},
|
||
{"global_admin_create", "global-admin", http.MethodPost, "", 403},
|
||
{"project_admin_create", "manager", http.MethodPost, "", 403},
|
||
{"global_admin_update", "global-admin", http.MethodPut, "project-a", 403},
|
||
{"viewer_update", "viewer", http.MethodPut, "project-a", 403},
|
||
{"cross_project", "manager", http.MethodPut, "project-b", 403},
|
||
{"missing", "primary-super-admin", http.MethodPut, "missing", 403},
|
||
{"manager_update", "manager", http.MethodPut, "project-a", 200},
|
||
{"default_protected", "primary-super-admin", http.MethodDelete, "default", 409},
|
||
{"member_reference", "primary-super-admin", http.MethodDelete, "project-a", 409},
|
||
{"manager_reference", "manager", http.MethodDelete, "project-a", 409},
|
||
{"cross_project_delete", "manager", http.MethodDelete, "project-b", 403},
|
||
} {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
path, body := "/api/v1/projects", `{"name":"新名称","description":"不能进入审计的说明"}`
|
||
if tc.project != "" {
|
||
path += "/" + tc.project
|
||
}
|
||
if tc.method == http.MethodDelete {
|
||
body = fmt.Sprintf(`{"confirmation":%q}`, tc.project)
|
||
}
|
||
response := serveUserRequest(f.server, f.cookies[tc.actor], tc.method, path, body)
|
||
if response.Code != tc.status {
|
||
t.Fatalf("项目权限结果错误:%d,%s", response.Code, response.Body.String())
|
||
}
|
||
if !strings.Contains(response.Header().Get("Cache-Control"), "no-store") {
|
||
t.Fatal("错误响应允许缓存")
|
||
}
|
||
if tc.status >= 400 {
|
||
log := f.lastAudit(t)
|
||
if log.OperatorID != tc.actor || log.Status != "failed" || !strings.Contains(log.Message, response.Header().Get(requestIDHeader)) || strings.Contains(log.Message, "不能进入审计") {
|
||
t.Fatalf("失败审计身份、关联或脱敏错误:%+v", log)
|
||
}
|
||
if (tc.project == "missing" || tc.project == "") && (log.ScopeType != repository.ScopeUnknown || log.ProjectID != "") {
|
||
t.Fatalf("伪造项目归属:%+v", log)
|
||
}
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestProjectLifecycleAPIRejectsInvalidInput(t *testing.T) {
|
||
f := newProjectMemberAPIFixture(t)
|
||
for _, tc := range []struct{ method, suffix, body string }{
|
||
{http.MethodPost, "", `{"name":"名称","id":"forged"}`},
|
||
{http.MethodPost, "", `{"name":"名称","actor_id":"forged"}`},
|
||
{http.MethodPost, "", `{"name":"名称","created_at":"2026-01-01"}`},
|
||
{http.MethodPost, "", `{"name":"名称"} {}`},
|
||
{http.MethodPost, "", `null`},
|
||
{http.MethodPost, "", `{"name":" "}`},
|
||
{http.MethodPost, "", `{"name":"` + strings.Repeat("a", 129) + `"}`},
|
||
{http.MethodPut, "/project-b", `{"name":"名称","owner_id":"forged"}`},
|
||
{http.MethodPut, "/project-b?ignored=1", `{"name":"名称"}`},
|
||
{http.MethodPut, "/UPPERCASE", `{"name":"名称"}`},
|
||
{http.MethodPut, "/" + strings.Repeat("a", 37), `{"name":"名称"}`},
|
||
{http.MethodDelete, "/UPPERCASE", `{"confirmation":"UPPERCASE"}`},
|
||
{http.MethodPut, "/project-b", strings.Repeat("x", 70*1024)},
|
||
{http.MethodDelete, "/project-b", `{}`},
|
||
{http.MethodDelete, "/project-b", `{"confirmation":"project-a"}`},
|
||
{http.MethodDelete, "/project-b", `{"confirmation":"project-b","actor_id":"forged"}`},
|
||
{http.MethodDelete, "/project-b?ignored=1", `{"confirmation":"project-b"}`},
|
||
} {
|
||
t.Run(fmt.Sprintf("%s-%d", tc.method, len(tc.body))+tc.suffix, func(t *testing.T) {
|
||
response := serveUserRequest(f.server, f.cookies["primary-super-admin"], tc.method, "/api/v1/projects"+tc.suffix, tc.body)
|
||
if response.Code != http.StatusBadRequest {
|
||
t.Fatalf("非法输入未拒绝:%d,%s", response.Code, response.Body.String())
|
||
}
|
||
if log := f.lastAudit(t); log.Status != "failed" || strings.Contains(log.Message, "forged") {
|
||
t.Fatalf("失败审计错误:%+v", log)
|
||
}
|
||
})
|
||
}
|
||
var count int
|
||
if err := f.db.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM projects").Scan(&count); err != nil || count != 3 {
|
||
t.Fatalf("非法请求改变项目:%d,%v", count, err)
|
||
}
|
||
}
|
||
|
||
func TestProjectLifecycleAPIAuditFailureRollsBack(t *testing.T) {
|
||
for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodDelete} {
|
||
for _, mode := range []string{"ABORT, 'sensitive-marker'", "IGNORE"} {
|
||
t.Run(method+mode, func(t *testing.T) {
|
||
f := newProjectMemberAPIFixture(t)
|
||
_, err := f.db.ExecContext(t.Context(), `CREATE TRIGGER reject_project_audit BEFORE INSERT ON audit_logs
|
||
WHEN NEW.resource_type='project' AND NEW.status='success' BEGIN SELECT RAISE(`+mode+`); END`)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
path, body := "/api/v1/projects", `{"name":"不能保存","description":"sensitive-marker"}`
|
||
if method != http.MethodPost {
|
||
path += "/project-b"
|
||
}
|
||
if method == http.MethodDelete {
|
||
body = `{"confirmation":"project-b"}`
|
||
}
|
||
response := serveUserRequest(f.server, f.cookies["primary-super-admin"], method, path, body)
|
||
if response.Code != http.StatusInternalServerError || strings.Contains(response.Body.String(), "sensitive-marker") {
|
||
t.Fatalf("审计失败误报或泄密:%d,%s", response.Code, response.Body.String())
|
||
}
|
||
var count int
|
||
if err := f.db.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM projects").Scan(&count); err != nil || count != 3 {
|
||
t.Fatalf("项目数量未回滚:%d,%v", count, err)
|
||
}
|
||
var name string
|
||
if err := f.db.QueryRowContext(t.Context(), "SELECT name FROM projects WHERE id='project-b'").Scan(&name); err != nil || name != "project-b" {
|
||
t.Fatalf("项目内容未回滚:%s,%v", name, err)
|
||
}
|
||
if log := f.lastAudit(t); log.Status != "failed" || log.OperatorID != "primary-super-admin" || strings.Contains(log.Message, "sensitive-marker") {
|
||
t.Fatalf("失败审计未保存或泄密:%+v", log)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
}
|