检查点:保存项目生命周期实现与待修复回归
This commit is contained in:
parent
fca7b796a9
commit
dcca17e371
@ -157,6 +157,7 @@ func NewWithConfigPath(ctx context.Context, cfg config.Config, configPath string
|
||||
Config: cfg, ConfigPath: configPath, Logger: logger, Repository: repo, Servers: serverRepo, Users: userRepo, SSHKeys: sshKeys, DeploymentFactory: deployment.SSHFactory{Secrets: secretStore},
|
||||
ProjectMembers: repository.NewSQLProjectMemberRepository(db.DB, runtimeDatabaseConfig.Driver),
|
||||
ProjectDirectory: repository.NewSQLProjectMemberRepository(db.DB, runtimeDatabaseConfig.Driver),
|
||||
ProjectLifecycle: repository.NewSQLProjectMemberRepository(db.DB, runtimeDatabaseConfig.Driver),
|
||||
SetupDNS: dnsVerification, Secrets: secretStore, Firewall: firewall.NewManager(), AliyunChecker: aliyunAuthChecker,
|
||||
IssueCertificate: workflow.IssueForHTTP,
|
||||
IssueSetupCertificate: func(ctx context.Context, request certificate.SetupCertificateRequest) (certificate.SavedResource, error) {
|
||||
|
||||
53
backend/internal/app/project_lifecycle_api_test.go
Normal file
53
backend/internal/app/project_lifecycle_api_test.go
Normal file
@ -0,0 +1,53 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/domain"
|
||||
)
|
||||
|
||||
// 走真实应用装配和会话登录,不能以测试夹具注入证明生产接线正确。
|
||||
func TestApplicationWiresProjectLifecycleAPI(t *testing.T) {
|
||||
cfg := readyApplicationConfig(t)
|
||||
application, err := NewWithConfigPath(t.Context(), cfg, filepath.Join(cfg.Paths.Config, "scert.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = application.Close() })
|
||||
var cookies []*http.Cookie
|
||||
request := func(method, path, body string, status int) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
for _, cookie := range cookies {
|
||||
req.AddCookie(cookie)
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
application.HTTP.Handler().ServeHTTP(response, req)
|
||||
if response.Code != status {
|
||||
t.Fatalf("实际应用请求失败:%s %s,%d,%s", method, path, response.Code, response.Body.String())
|
||||
}
|
||||
return response
|
||||
}
|
||||
login := request(http.MethodPost, "/api/v1/auth/login", `{"identifier":"admin","password":"Test1234!"}`, 200)
|
||||
cookies = login.Result().Cookies()
|
||||
created := request(http.MethodPost, "/api/v1/projects", `{"name":"实际应用项目","description":"接线验证"}`, 201)
|
||||
var item domain.Project
|
||||
if err := json.Unmarshal(created.Body.Bytes(), &item); err != nil || item.ID == "" {
|
||||
t.Fatalf("创建未返回标识:%v", err)
|
||||
}
|
||||
path := "/api/v1/projects/" + item.ID
|
||||
request(http.MethodPut, path, `{"name":"修改后的项目"}`, 200)
|
||||
directory := request(http.MethodGet, "/api/v1/projects?page_size=30", "", 200)
|
||||
if !strings.Contains(directory.Body.String(), `"can_create_project":true`) || !strings.Contains(directory.Body.String(), "修改后的项目") {
|
||||
t.Fatalf("真实目录未更新或缺少能力:%s", directory.Body.String())
|
||||
}
|
||||
request(http.MethodDelete, path, fmt.Sprintf(`{"confirmation":%q}`, item.ID), 204)
|
||||
request(http.MethodPut, path, `{"name":"已删除项目"}`, 403)
|
||||
}
|
||||
135
backend/internal/httpapi/project_lifecycle_api.go
Normal file
135
backend/internal/httpapi/project_lifecycle_api.go
Normal file
@ -0,0 +1,135 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/domain"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/repository"
|
||||
)
|
||||
|
||||
func (s *Server) registerProjectLifecycleRoutes() {
|
||||
api := s.router.Group("/api/v1/projects", privateProjectResponse)
|
||||
api.POST("", s.projectFailureAudit("project.create"), s.requireAuthenticated, s.createProject)
|
||||
api.PUT("/:projectID", s.projectFailureAudit("project.update"), s.requireAuthenticated, s.updateProject)
|
||||
api.DELETE("/:projectID", s.projectFailureAudit("project.delete"), s.requireAuthenticated, s.deleteProject)
|
||||
}
|
||||
|
||||
func (s *Server) projectLifecycleReady(c *gin.Context, creating bool) bool {
|
||||
if s.projectLifecycle == nil {
|
||||
s.writeError(c, http.StatusServiceUnavailable, "项目管理服务尚未就绪", nil)
|
||||
return false
|
||||
}
|
||||
if c.Request.URL.RawQuery != "" || (!creating && !validMemberIdentifier(c.Param("projectID"))) {
|
||||
s.writeError(c, http.StatusBadRequest, "项目请求参数无效", nil)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type projectDetailsRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func (s *Server) projectDetails(c *gin.Context) (projectDetailsRequest, bool) {
|
||||
var input projectDetailsRequest
|
||||
if err := bindJSON(c, &input, jsonRequestBodyLimit); err != nil {
|
||||
s.writeError(c, http.StatusBadRequest, "项目资料格式无效", nil)
|
||||
return input, false
|
||||
}
|
||||
item := (domain.Project{ID: "validation", Name: input.Name, Description: input.Description}).Normalize()
|
||||
if err := item.Validate(); err != nil {
|
||||
s.writeError(c, http.StatusBadRequest, err.Error(), nil)
|
||||
return input, false
|
||||
}
|
||||
return projectDetailsRequest{Name: item.Name, Description: item.Description}, true
|
||||
}
|
||||
|
||||
func (s *Server) createProject(c *gin.Context) {
|
||||
if !s.projectLifecycleReady(c, true) {
|
||||
return
|
||||
}
|
||||
input, ok := s.projectDetails(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
item, err := s.projectLifecycle.CreateManagedProject(c.Request.Context(), c.GetString(authenticatedActorKey), input.Name, input.Description)
|
||||
if err != nil {
|
||||
s.writeProjectLifecycleError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, item)
|
||||
}
|
||||
|
||||
func (s *Server) updateProject(c *gin.Context) {
|
||||
if !s.projectLifecycleReady(c, false) {
|
||||
return
|
||||
}
|
||||
input, ok := s.projectDetails(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := s.projectLifecycle.UpdateManagedProject(c.Request.Context(), c.GetString(authenticatedActorKey), c.Param("projectID"), input.Name, input.Description); err != nil {
|
||||
s.writeProjectLifecycleError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "项目资料已更新"})
|
||||
}
|
||||
|
||||
func (s *Server) deleteProject(c *gin.Context) {
|
||||
if !s.projectLifecycleReady(c, false) {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Confirmation string `json:"confirmation"`
|
||||
}
|
||||
if err := bindJSON(c, &input, jsonRequestBodyLimit); err != nil || input.Confirmation != c.Param("projectID") {
|
||||
s.writeError(c, http.StatusBadRequest, "请确认要删除的项目,确认标识必须与目标项目一致", nil)
|
||||
return
|
||||
}
|
||||
if err := s.projectLifecycle.DeleteManagedProject(c.Request.Context(), c.GetString(authenticatedActorKey), c.Param("projectID")); err != nil {
|
||||
s.writeProjectLifecycleError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) writeProjectLifecycleError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, repository.ErrProjectAccessDenied):
|
||||
s.writeError(c, http.StatusForbidden, "无权访问项目或执行此操作", nil)
|
||||
case errors.Is(err, repository.ErrDefaultProject):
|
||||
s.writeError(c, http.StatusConflict, "默认项目不能删除", nil)
|
||||
case errors.Is(err, repository.ErrProjectReferenced):
|
||||
s.writeError(c, http.StatusConflict, "项目仍有关联资源或成员,请先移出后重试", nil)
|
||||
default:
|
||||
s.writeError(c, http.StatusInternalServerError, "项目操作失败,请稍后重试", nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) projectFailureAudit(action string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Next()
|
||||
if c.Writer.Status() < 400 || s.projectLifecycle == nil {
|
||||
return
|
||||
}
|
||||
projectID := c.Param("projectID")
|
||||
if !validMemberIdentifier(projectID) {
|
||||
projectID = ""
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(c.Request.Context()), 3*time.Second)
|
||||
defer cancel()
|
||||
err := s.projectLifecycle.RecordProjectFailure(ctx, repository.ProjectFailure{
|
||||
ActorID: c.GetString(authenticatedActorKey), ProjectID: projectID, Action: action,
|
||||
StatusCode: c.Writer.Status(), RequestID: requestIDFromContext(c),
|
||||
})
|
||||
if err != nil && s.logger != nil {
|
||||
s.logger.Error("项目失败审计未保存", "request_id", requestIDFromContext(c), "status", c.Writer.Status())
|
||||
}
|
||||
}
|
||||
}
|
||||
162
backend/internal/httpapi/project_lifecycle_api_test.go
Normal file
162
backend/internal/httpapi/project_lifecycle_api_test.go
Normal file
@ -0,0 +1,162 @@
|
||||
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, "/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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
115
backend/internal/httpapi/project_lifecycle_boundary_test.go
Normal file
115
backend/internal/httpapi/project_lifecycle_boundary_test.go
Normal file
@ -0,0 +1,115 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/domain"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/repository"
|
||||
)
|
||||
|
||||
type projectLifecycleBoundaryRepository struct {
|
||||
repository.ProjectLifecycleRepository
|
||||
before func()
|
||||
audit func(context.Context)
|
||||
}
|
||||
|
||||
func (r projectLifecycleBoundaryRepository) CreateManagedProject(ctx context.Context, actor, name, description string) (domain.Project, error) {
|
||||
r.before()
|
||||
return r.ProjectLifecycleRepository.CreateManagedProject(ctx, actor, name, description)
|
||||
}
|
||||
|
||||
func (r projectLifecycleBoundaryRepository) UpdateManagedProject(ctx context.Context, actor, id, name, description string) error {
|
||||
r.before()
|
||||
return r.ProjectLifecycleRepository.UpdateManagedProject(ctx, actor, id, name, description)
|
||||
}
|
||||
|
||||
func (r projectLifecycleBoundaryRepository) DeleteManagedProject(ctx context.Context, actor, id string) error {
|
||||
r.before()
|
||||
return r.ProjectLifecycleRepository.DeleteManagedProject(ctx, actor, id)
|
||||
}
|
||||
|
||||
func (r projectLifecycleBoundaryRepository) RecordProjectFailure(ctx context.Context, failure repository.ProjectFailure) error {
|
||||
if r.audit != nil {
|
||||
r.audit(ctx)
|
||||
}
|
||||
return r.ProjectLifecycleRepository.RecordProjectFailure(ctx, failure)
|
||||
}
|
||||
|
||||
func TestProjectLifecycleAPIRechecksAfterAuthentication(t *testing.T) {
|
||||
for _, change := range []string{"disable", "demote"} {
|
||||
for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodDelete} {
|
||||
t.Run(change+method, func(t *testing.T) {
|
||||
f := newProjectMemberAPIFixture(t)
|
||||
called := false
|
||||
f.server.projectLifecycle = projectLifecycleBoundaryRepository{ProjectLifecycleRepository: f.members, before: func() {
|
||||
called = true
|
||||
query := "UPDATE users SET status='disabled' WHERE id='primary-super-admin'"
|
||||
if change == "demote" {
|
||||
query = "UPDATE users SET role='admin' WHERE id='primary-super-admin'"
|
||||
}
|
||||
if _, err := f.db.ExecContext(t.Context(), query); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}}
|
||||
path, body := "/api/v1/projects", `{"name":"不能保存"}`
|
||||
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)
|
||||
log := f.lastAudit(t)
|
||||
if !called || response.Code != http.StatusForbidden || log.OperatorID != "primary-super-admin" || log.Status != "failed" {
|
||||
t.Fatalf("会话后的权限变化未生效:%d,%+v", response.Code, 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
t.Run("project_role_revoked", func(t *testing.T) {
|
||||
f := newProjectMemberAPIFixture(t)
|
||||
f.server.projectLifecycle = projectLifecycleBoundaryRepository{ProjectLifecycleRepository: f.members, before: func() {
|
||||
if _, err := f.db.ExecContext(t.Context(), "DELETE FROM project_members WHERE user_id='manager'"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}}
|
||||
response := serveUserRequest(f.server, f.cookies["manager"], http.MethodPut, "/api/v1/projects/project-a", `{"name":"不能保存"}`)
|
||||
if response.Code != 403 {
|
||||
t.Fatalf("项目角色撤销后仍允许修改:%d", response.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestProjectLifecycleAPICanceledRequestKeepsBoundedAudit(t *testing.T) {
|
||||
f := newProjectMemberAPIFixture(t)
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
audited := false
|
||||
f.server.projectLifecycle = projectLifecycleBoundaryRepository{
|
||||
ProjectLifecycleRepository: f.members, before: cancel,
|
||||
audit: func(ctx context.Context) {
|
||||
audited = true
|
||||
deadline, ok := ctx.Deadline()
|
||||
if ctx.Err() != nil || !ok || time.Until(deadline) > 3*time.Second {
|
||||
t.Fatal("失败审计没有独立有界上下文")
|
||||
}
|
||||
},
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/projects", strings.NewReader(`{"name":"取消项目"}`)).WithContext(ctx)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.AddCookie(f.cookies["primary-super-admin"])
|
||||
response := httptest.NewRecorder()
|
||||
f.server.Handler().ServeHTTP(response, request)
|
||||
if !audited || response.Code != 500 || f.lastAudit(t).Status != "failed" {
|
||||
t.Fatalf("取消请求后审计丢失:%d", response.Code)
|
||||
}
|
||||
}
|
||||
@ -48,7 +48,7 @@ func newProjectMemberAPIFixture(t *testing.T) projectMemberAPIFixture {
|
||||
db: db.DB, members: repository.NewSQLProjectMemberRepository(db.DB, cfg.Database.Driver),
|
||||
audit: repository.NewSQLAuditRepository(db.DB, cfg.Database.Driver), cookies: make(map[string]*http.Cookie),
|
||||
}
|
||||
f.server = New(Dependencies{Config: cfg, Users: users, ProjectMembers: f.members, ProjectDirectory: f.members, Audit: f.audit})
|
||||
f.server = New(Dependencies{Config: cfg, Users: users, ProjectMembers: f.members, ProjectDirectory: f.members, ProjectLifecycle: f.members, Audit: f.audit})
|
||||
for _, account := range []struct{ id, role string }{
|
||||
{"primary-super-admin", repository.UserRoleSuperAdmin}, {"manager", repository.UserRoleUser},
|
||||
{"global-admin", repository.UserRoleAdmin}, {"viewer", repository.UserRoleUser}, {"target", repository.UserRoleUser},
|
||||
|
||||
@ -50,6 +50,7 @@ type Dependencies struct {
|
||||
Users repository.UserRepository
|
||||
ProjectMembers repository.ProjectMemberRepository
|
||||
ProjectDirectory repository.ProjectDirectoryRepository
|
||||
ProjectLifecycle repository.ProjectLifecycleRepository
|
||||
SSHKeys *sshkey.Service
|
||||
DeploymentFactory deployment.ClientFactory
|
||||
SetupDNS setupDNSVerifier
|
||||
@ -83,6 +84,7 @@ type Server struct {
|
||||
users repository.UserRepository
|
||||
projectMembers repository.ProjectMemberRepository
|
||||
projectDirectory repository.ProjectDirectoryRepository
|
||||
projectLifecycle repository.ProjectLifecycleRepository
|
||||
sshKeys *sshkey.Service
|
||||
deploymentFactory deployment.ClientFactory
|
||||
setupDNS setupDNSVerifier
|
||||
@ -145,6 +147,7 @@ func New(deps Dependencies) *Server {
|
||||
users: deps.Users,
|
||||
projectMembers: deps.ProjectMembers,
|
||||
projectDirectory: deps.ProjectDirectory,
|
||||
projectLifecycle: deps.ProjectLifecycle,
|
||||
sshKeys: deps.SSHKeys,
|
||||
deploymentFactory: deps.DeploymentFactory,
|
||||
issueCertificate: deps.IssueCertificate,
|
||||
@ -193,6 +196,7 @@ func (s *Server) registerRoutes() {
|
||||
userAPI.PUT("/:id", s.requireSuperAdmin, s.updateUser)
|
||||
userAPI.DELETE("/:id", s.requireSuperAdmin, s.deleteUser)
|
||||
s.registerProjectMemberRoutes()
|
||||
s.registerProjectLifecycleRoutes()
|
||||
s.router.GET("/api/v1/projects", privateProjectResponse, s.requireAuthenticated, s.listVisibleProjects)
|
||||
adminAPI.GET("/domains", s.listDomains)
|
||||
adminAPI.POST("/domains", s.dnsOperation, s.createDomain)
|
||||
|
||||
@ -17,13 +17,16 @@ type VisibleProject struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
CanManageMembers bool `json:"can_manage_members"`
|
||||
CanManageProject bool `json:"can_manage_project"`
|
||||
CanDeleteProject bool `json:"can_delete_project"`
|
||||
}
|
||||
|
||||
type VisibleProjectPage struct {
|
||||
Items []VisibleProject `json:"items"`
|
||||
PageSize int `json:"page_size"`
|
||||
HasMore bool `json:"has_more"`
|
||||
NextCursor string `json:"next_cursor"`
|
||||
Items []VisibleProject `json:"items"`
|
||||
PageSize int `json:"page_size"`
|
||||
HasMore bool `json:"has_more"`
|
||||
NextCursor string `json:"next_cursor"`
|
||||
CanCreateProject bool `json:"can_create_project"`
|
||||
}
|
||||
|
||||
var _ ProjectDirectoryRepository = (*SQLProjectMemberRepository)(nil)
|
||||
@ -38,7 +41,7 @@ func (r *SQLProjectMemberRepository) ListVisibleProjects(ctx context.Context, ac
|
||||
}
|
||||
// 授权、可见性过滤与读取共用一条 SQL;有效账户没有项目时仍返回授权门行。
|
||||
// 使用 EXISTS 避免一个账户的多个角色造成重复项目和错误分页。
|
||||
rows, err := r.db.QueryContext(ctx, Rebind(r.driver, `SELECT a.id, p.id, p.name, p.description,
|
||||
rows, err := r.db.QueryContext(ctx, Rebind(r.driver, `SELECT a.id, a.role, p.id, p.name, p.description,
|
||||
CASE WHEN a.role = 'super_admin' OR EXISTS (
|
||||
SELECT 1 FROM project_members manager WHERE manager.project_id = p.id
|
||||
AND manager.user_id = a.id AND manager.role = 'project_admin'
|
||||
@ -57,16 +60,18 @@ func (r *SQLProjectMemberRepository) ListVisibleProjects(ctx context.Context, ac
|
||||
defer rows.Close()
|
||||
allowed := false
|
||||
for rows.Next() {
|
||||
var actor string
|
||||
var actor, globalRole string
|
||||
var id, name, description sql.NullString
|
||||
var manager int
|
||||
if err := rows.Scan(&actor, &id, &name, &description, &manager); err != nil {
|
||||
if err := rows.Scan(&actor, &globalRole, &id, &name, &description, &manager); err != nil {
|
||||
return page, fmt.Errorf("读取可见项目失败:%w", err)
|
||||
}
|
||||
allowed = true
|
||||
page.CanCreateProject = globalRole == UserRoleSuperAdmin
|
||||
if id.Valid {
|
||||
page.Items = append(page.Items, VisibleProject{
|
||||
ID: id.String, Name: name.String, Description: description.String, CanManageMembers: manager == 1,
|
||||
CanManageProject: manager == 1, CanDeleteProject: manager == 1 && id.String != "default",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -67,6 +67,9 @@ func checkProjectDirectory(t *testing.T, cfg config.DatabaseConfig) {
|
||||
t.Fatalf("项目可见性错误:%s,%+v,%v", tc.actor, page, err)
|
||||
}
|
||||
for _, item := range page.Items {
|
||||
if page.CanCreateProject != (tc.actor == "root") || item.CanManageProject != tc.manage || item.CanDeleteProject != (tc.manage && item.ID != domain.DefaultProjectID) {
|
||||
t.Fatalf("生命周期能力错误:%s,%+v,%+v", tc.actor, page, item)
|
||||
}
|
||||
if item.CanManageMembers != tc.manage || (tc.actor != "root" && item.ID == domain.DefaultProjectID) {
|
||||
t.Fatalf("全局身份被错误映射为项目权限:%s,%+v", tc.actor, item)
|
||||
}
|
||||
@ -113,7 +116,7 @@ func checkProjectDirectory(t *testing.T, cfg config.DatabaseConfig) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page, err := repo.ListVisibleProjects(t.Context(), "manager", "", 5)
|
||||
if err != nil || len(page.Items) == 0 || page.Items[0].CanManageMembers {
|
||||
if err != nil || len(page.Items) == 0 || page.Items[0].CanManageMembers || page.Items[0].CanManageProject || page.Items[0].CanDeleteProject || page.CanCreateProject {
|
||||
t.Fatalf("撤销管理角色未生效:%+v,%v", page, err)
|
||||
}
|
||||
if err := repo.RevokeProjectRole(t.Context(), "root", "visible-00", "manager", domain.ProjectRoleReadOnly); err != nil {
|
||||
|
||||
155
backend/internal/repository/project_lifecycle.go
Normal file
155
backend/internal/repository/project_lifecycle.go
Normal file
@ -0,0 +1,155 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/domain"
|
||||
)
|
||||
|
||||
var ErrProjectReferenced = errors.New("项目仍有关联资源或成员,请先移出后重试")
|
||||
|
||||
// ProjectLifecycleRepository 是面向已认证业务入口的仓储,不能用基础项目仓储替代授权检查。
|
||||
type ProjectLifecycleRepository interface {
|
||||
CreateManagedProject(context.Context, string, string, string) (domain.Project, error)
|
||||
UpdateManagedProject(context.Context, string, string, string, string) error
|
||||
DeleteManagedProject(context.Context, string, string) error
|
||||
RecordProjectFailure(context.Context, ProjectFailure) error
|
||||
}
|
||||
|
||||
var _ ProjectLifecycleRepository = (*SQLProjectMemberRepository)(nil)
|
||||
|
||||
func (r *SQLProjectMemberRepository) CreateManagedProject(ctx context.Context, actorID, name, description string) (domain.Project, error) {
|
||||
item, err := domain.NewProject(name, description)
|
||||
if err != nil {
|
||||
return domain.Project{}, err
|
||||
}
|
||||
err = r.changeManagedProject(ctx, actorID, item, "project.create")
|
||||
if err != nil {
|
||||
return domain.Project{}, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (r *SQLProjectMemberRepository) UpdateManagedProject(ctx context.Context, actorID, id, name, description string) error {
|
||||
item := (domain.Project{ID: id, Name: name, Description: description}).Normalize()
|
||||
if err := item.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
item.UpdatedAt = time.Now().UTC()
|
||||
return r.changeManagedProject(ctx, actorID, item, "project.update")
|
||||
}
|
||||
|
||||
func (r *SQLProjectMemberRepository) DeleteManagedProject(ctx context.Context, actorID, id string) error {
|
||||
return r.changeManagedProject(ctx, actorID, domain.Project{ID: strings.TrimSpace(id)}, "project.delete")
|
||||
}
|
||||
|
||||
// 授权在实际变更语句内完成;成功审计与变更同事务,不删除成员或业务资源来迎合删除请求。
|
||||
func (r *SQLProjectMemberRepository) changeManagedProject(ctx context.Context, actorID string, item domain.Project, action string) error {
|
||||
if err := r.ensureDB(); err != nil {
|
||||
return err
|
||||
}
|
||||
actorID = strings.TrimSpace(actorID)
|
||||
if actorID == "" || item.ID == "" {
|
||||
return ErrProjectAccessDenied
|
||||
}
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("开始项目变更事务失败:%w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if r.driver == "mysql" {
|
||||
var count int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_schema=DATABASE() AND table_name IN ('projects', 'audit_logs') AND engine='InnoDB'`).Scan(&count); err != nil {
|
||||
return fmt.Errorf("检查项目审计存储失败:%w", err)
|
||||
}
|
||||
if count != 2 {
|
||||
return errors.New("项目与审计表必须使用 InnoDB,拒绝无法保证回滚的变更")
|
||||
}
|
||||
}
|
||||
if err := r.writeManagedProject(ctx, tx, actorID, item, action); err != nil {
|
||||
return err
|
||||
}
|
||||
// 不把用户输入的名称、说明或请求正文写入审计;删除后仍保留真实项目范围快照。
|
||||
result, err := tx.ExecContext(ctx, Rebind(r.driver, `INSERT INTO audit_logs
|
||||
(action, resource_type, resource_id, status, message, created_at, scope_type, project_id, task_id, operator_id)
|
||||
VALUES (?, 'project', ?, 'success', ?, ?, 'project', ?, '', ?)`),
|
||||
action, item.ID, "项目变更已完成", time.Now().UTC(), item.ID, actorID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("保存项目变更审计失败:%w", err)
|
||||
}
|
||||
count, err := result.RowsAffected()
|
||||
if err != nil || count != 1 {
|
||||
return errors.New("项目变更审计未写入,已回滚本次变更")
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("提交项目变更失败:%w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const projectHasReferences = `(EXISTS (SELECT 1 FROM domains d WHERE d.project_id = projects.id)
|
||||
OR EXISTS (SELECT 1 FROM servers s WHERE s.project_id = projects.id)
|
||||
OR EXISTS (SELECT 1 FROM domain_servers b WHERE b.project_id = projects.id)
|
||||
OR EXISTS (SELECT 1 FROM project_members m WHERE m.project_id = projects.id))`
|
||||
|
||||
func (r *SQLProjectMemberRepository) writeManagedProject(ctx context.Context, tx *sql.Tx, actorID string, item domain.Project, action string) error {
|
||||
var query string
|
||||
var args []any
|
||||
switch action {
|
||||
case "project.create":
|
||||
query = `INSERT INTO projects (id, name, description, created_at, updated_at)
|
||||
SELECT ?, ?, ?, ?, ? FROM users a WHERE a.id = ? AND a.status = 'active' AND a.role = 'super_admin'`
|
||||
args = []any{item.ID, item.Name, item.Description, item.CreatedAt, item.UpdatedAt, actorID}
|
||||
case "project.update":
|
||||
query = `UPDATE projects SET name = ?, description = ?, updated_at = ? WHERE id = ? AND ` + projectManagerPredicate
|
||||
args = []any{item.Name, item.Description, item.UpdatedAt, item.ID, actorID, item.ID}
|
||||
case "project.delete":
|
||||
query = `DELETE FROM projects WHERE id = ? AND id <> ? AND ` + projectManagerPredicate + ` AND NOT ` + projectHasReferences
|
||||
args = []any{item.ID, domain.DefaultProjectID, actorID, item.ID}
|
||||
default:
|
||||
return errors.New("项目变更动作无效")
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, Rebind(r.driver, query), args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("执行项目变更失败:%w", err)
|
||||
}
|
||||
count, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取项目变更结果失败:%w", err)
|
||||
}
|
||||
if count == 1 {
|
||||
return nil
|
||||
}
|
||||
if count != 0 || action == "project.create" {
|
||||
return ErrProjectAccessDenied
|
||||
}
|
||||
var name, description string
|
||||
var referenced bool
|
||||
err = tx.QueryRowContext(ctx, Rebind(r.driver, `SELECT name, description, `+projectHasReferences+
|
||||
` FROM projects WHERE id = ? AND `+projectManagerPredicate), item.ID, actorID, item.ID).Scan(&name, &description, &referenced)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrProjectAccessDenied
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("核对项目变更条件失败:%w", err)
|
||||
}
|
||||
if action == "project.delete" {
|
||||
if item.ID == domain.DefaultProjectID {
|
||||
return ErrDefaultProject
|
||||
}
|
||||
if referenced {
|
||||
return ErrProjectReferenced
|
||||
}
|
||||
}
|
||||
// MySQL 秒级时间字段与相同资料可能得到零变更行;仅在当前仍有权限且内容一致时认可幂等更新。
|
||||
if action == "project.update" && r.driver == "mysql" && name == item.Name && description == item.Description {
|
||||
return nil
|
||||
}
|
||||
return errors.New("项目变更未实际生效,请刷新后重试")
|
||||
}
|
||||
43
backend/internal/repository/project_lifecycle_failure.go
Normal file
43
backend/internal/repository/project_lifecycle_failure.go
Normal file
@ -0,0 +1,43 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ProjectFailure struct {
|
||||
ActorID string
|
||||
ProjectID string
|
||||
Action string
|
||||
StatusCode int
|
||||
RequestID string
|
||||
}
|
||||
|
||||
// 失败记录只保留已验证身份、实际范围和安全诊断,不包含请求正文或数据库原始错误。
|
||||
func (r *SQLProjectMemberRepository) RecordProjectFailure(ctx context.Context, failure ProjectFailure) error {
|
||||
if err := r.ensureDB(); err != nil {
|
||||
return err
|
||||
}
|
||||
if failure.Action != "project.create" && failure.Action != "project.update" && failure.Action != "project.delete" {
|
||||
return errors.New("项目失败审计动作无效")
|
||||
}
|
||||
if failure.StatusCode < 400 || failure.StatusCode > 599 || len(failure.ActorID) > 128 || len(failure.ProjectID) > 128 || len(failure.RequestID) > 128 {
|
||||
return errors.New("项目失败审计参数无效")
|
||||
}
|
||||
result, err := r.db.ExecContext(ctx, Rebind(r.driver, `INSERT INTO audit_logs
|
||||
(action, resource_type, resource_id, status, message, created_at, scope_type, project_id, task_id, operator_id)
|
||||
SELECT ?, 'project', ?, 'failed', ?, ?, CASE WHEN p.id IS NULL THEN 'unknown' ELSE 'project' END,
|
||||
COALESCE(p.id, ''), '', ? FROM (SELECT 1 AS marker) audit_event LEFT JOIN projects p ON p.id = ?`),
|
||||
failure.Action, failure.ProjectID, fmt.Sprintf("请求被拒绝或执行失败:HTTP %d;请求编号:%s", failure.StatusCode, failure.RequestID),
|
||||
time.Now().UTC(), failure.ActorID, failure.ProjectID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("保存项目失败审计失败:%w", err)
|
||||
}
|
||||
count, err := result.RowsAffected()
|
||||
if err != nil || count != 1 {
|
||||
return errors.New("项目失败审计未写入")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
300
backend/internal/repository/project_lifecycle_test.go
Normal file
300
backend/internal/repository/project_lifecycle_test.go
Normal file
@ -0,0 +1,300 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/config"
|
||||
"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/testsupport/mysqltest"
|
||||
)
|
||||
|
||||
func TestProjectLifecycleSQLite(t *testing.T) {
|
||||
checkProjectLifecycle(t, func(t *testing.T) config.DatabaseConfig {
|
||||
cfg := config.Default(t.TempDir()).Database
|
||||
cfg.DSN = filepath.Join(t.TempDir(), "project-lifecycle.db")
|
||||
return cfg
|
||||
})
|
||||
}
|
||||
|
||||
func TestProjectLifecycleMySQLReal(t *testing.T) {
|
||||
server := mysqltest.New(t)
|
||||
checkProjectLifecycle(t, server.Database)
|
||||
}
|
||||
|
||||
func checkProjectLifecycle(t *testing.T, makeConfig func(*testing.T) config.DatabaseConfig) {
|
||||
t.Helper()
|
||||
run := func(name string, check func(*testing.T, *sql.DB, *SQLProjectMemberRepository, string)) {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cfg := makeConfig(t)
|
||||
db, err := database.Open(t.Context(), cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err := database.Migrate(t.Context(), db.DB, cfg.Driver); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, user := range []struct{ id, role, status string }{
|
||||
{"root", "super_admin", "active"}, {"manager", "user", "active"},
|
||||
{"global-admin", "admin", "active"}, {"viewer", "user", "active"}, {"disabled", "super_admin", "disabled"},
|
||||
} {
|
||||
_, err := db.DB.ExecContext(t.Context(), Rebind(cfg.Driver, `INSERT INTO users
|
||||
(id, username, email, password_hash, role, status) VALUES (?, ?, ?, 'test-only-no-login', ?, ?)`),
|
||||
user.id, user.id, user.id+"@example.invalid", user.role, user.status)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
check(t, db.DB, NewSQLProjectMemberRepository(db.DB, cfg.Driver), cfg.Driver)
|
||||
})
|
||||
}
|
||||
run("create_update_delete_and_history", func(t *testing.T, db *sql.DB, repo *SQLProjectMemberRepository, driver string) {
|
||||
item, err := repo.CreateManagedProject(t.Context(), "root", " 项目一 ", "说明")
|
||||
if err != nil || item.ID == "" || item.ID == domain.DefaultProjectID || item.Name != "项目一" {
|
||||
t.Fatalf("创建失败:%+v,%v", item, err)
|
||||
}
|
||||
projects := NewSQLProjectRepository(db, driver)
|
||||
before, err := projects.GetProject(t.Context(), item.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := repo.UpdateManagedProject(t.Context(), "root", item.ID, "项目二", "新说明"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
after, err := projects.GetProject(t.Context(), item.ID)
|
||||
if err != nil || !after.CreatedAt.Equal(before.CreatedAt) || after.ID != before.ID || after.Name != "项目二" {
|
||||
t.Fatalf("更新破坏身份:%+v,%v", after, err)
|
||||
}
|
||||
if err := repo.DeleteManagedProject(t.Context(), "root", item.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := projects.GetProject(t.Context(), item.ID); !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Fatalf("项目未删除:%v", err)
|
||||
}
|
||||
var count int
|
||||
if err := db.QueryRowContext(t.Context(), Rebind(driver, `SELECT COUNT(*) FROM audit_logs WHERE resource_type='project'
|
||||
AND resource_id=? AND project_id=? AND scope_type='project' AND operator_id='root' AND status='success'`), item.ID, item.ID).Scan(&count); err != nil || count != 4 {
|
||||
t.Fatalf("删除后丢失真实审计:%d,%v", count, err)
|
||||
}
|
||||
})
|
||||
for _, actor := range []string{"", "missing", "manager", "global-admin", "viewer", "disabled"} {
|
||||
run("create_denied_"+actor, func(t *testing.T, db *sql.DB, repo *SQLProjectMemberRepository, driver string) {
|
||||
if _, err := repo.CreateManagedProject(t.Context(), actor, "无权创建", "不应保存"); !errors.Is(err, ErrProjectAccessDenied) {
|
||||
t.Fatalf("错误授权:%v", err)
|
||||
}
|
||||
var count int
|
||||
if err := db.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM projects`).Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("拒绝后产生项目:%d,%v", count, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
run("project_manager_and_revocation", func(t *testing.T, db *sql.DB, repo *SQLProjectMemberRepository, driver string) {
|
||||
item, err := repo.CreateManagedProject(t.Context(), "root", "权限测试", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repo.GrantProjectRole(t.Context(), "root", item.ID, "manager", domain.ProjectRoleAdmin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repo.UpdateManagedProject(t.Context(), "manager", item.ID, "合法修改", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, actor := range []string{"viewer", "global-admin", "disabled"} {
|
||||
if err := repo.UpdateManagedProject(t.Context(), actor, item.ID, "越权修改", ""); !errors.Is(err, ErrProjectAccessDenied) {
|
||||
t.Fatalf("错误更新授权:%s,%v", actor, err)
|
||||
}
|
||||
if err := repo.DeleteManagedProject(t.Context(), actor, item.ID); !errors.Is(err, ErrProjectAccessDenied) {
|
||||
t.Fatalf("错误删除授权:%s,%v", actor, err)
|
||||
}
|
||||
}
|
||||
if err := repo.DeleteManagedProject(t.Context(), "manager", item.ID); !errors.Is(err, ErrProjectReferenced) {
|
||||
t.Fatalf("不能自动清理成员:%v", err)
|
||||
}
|
||||
if err := repo.RevokeProjectRole(t.Context(), "root", item.ID, "manager", domain.ProjectRoleAdmin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repo.UpdateManagedProject(t.Context(), "manager", item.ID, "撤权后修改", ""); !errors.Is(err, ErrProjectAccessDenied) {
|
||||
t.Fatalf("撤权未生效:%v", err)
|
||||
}
|
||||
if err := repo.DeleteManagedProject(t.Context(), "manager", item.ID); !errors.Is(err, ErrProjectAccessDenied) {
|
||||
t.Fatalf("撤权后仍可删除:%v", err)
|
||||
}
|
||||
if err := repo.DeleteManagedProject(t.Context(), "root", item.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
for _, kind := range []string{"domain", "server", "member"} {
|
||||
run("reference_"+kind, func(t *testing.T, db *sql.DB, repo *SQLProjectMemberRepository, driver string) {
|
||||
item, err := repo.CreateManagedProject(t.Context(), "root", "引用保护", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
switch kind {
|
||||
case "domain":
|
||||
resource, err := domain.NewDomain("lifecycle.example.test", domain.CertificateSingle, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resource.ProjectID = item.ID
|
||||
if err := NewSQLDomainRepository(db, driver).Create(t.Context(), resource); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case "server":
|
||||
resource := deployment.Server{ID: "server-a", Name: "项目服务器", Address: "192.0.2.10", Port: 22, Username: "deploy",
|
||||
PrivateKeyRef: "secret://ssh/test", HostKeyFingerprint: "SHA256:test", CertificatePath: "/test/cert.pem",
|
||||
PrivateKeyPath: "/test/key.pem", CertificateChainPath: "/test/chain.pem", WebServer: deployment.WebServerNginx}
|
||||
resource.ProjectID = item.ID
|
||||
if err := NewSQLServerRepository(db, driver).CreateServer(t.Context(), resource); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case "member":
|
||||
if err := repo.GrantProjectRole(t.Context(), "root", item.ID, "viewer", domain.ProjectRoleReadOnly); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := repo.DeleteManagedProject(t.Context(), "root", item.ID); !errors.Is(err, ErrProjectReferenced) {
|
||||
t.Fatalf("引用保护失败:%v", err)
|
||||
}
|
||||
if _, err := NewSQLProjectRepository(db, driver).GetProject(t.Context(), item.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
run("default_missing_and_cancelled", func(t *testing.T, db *sql.DB, repo *SQLProjectMemberRepository, driver string) {
|
||||
if err := repo.DeleteManagedProject(t.Context(), "root", domain.DefaultProjectID); !errors.Is(err, ErrDefaultProject) {
|
||||
t.Fatalf("默认项目删除:%v", err)
|
||||
}
|
||||
if err := repo.DeleteManagedProject(t.Context(), "viewer", domain.DefaultProjectID); !errors.Is(err, ErrProjectAccessDenied) {
|
||||
t.Fatalf("默认项目泄露权限:%v", err)
|
||||
}
|
||||
if err := repo.DeleteManagedProject(t.Context(), "root", "missing"); !errors.Is(err, ErrProjectAccessDenied) {
|
||||
t.Fatalf("不存在项目:%v", err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
if _, err := repo.CreateManagedProject(ctx, "root", "取消", ""); err == nil {
|
||||
t.Fatal("取消后创建成功")
|
||||
}
|
||||
})
|
||||
for _, action := range []string{"create", "update", "delete"} {
|
||||
run("audit_rollback_"+action, func(t *testing.T, db *sql.DB, repo *SQLProjectMemberRepository, driver string) {
|
||||
item, err := repo.CreateManagedProject(t.Context(), "root", "必须保留", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
trigger := `CREATE TRIGGER fail_project_audit BEFORE INSERT ON audit_logs BEGIN SELECT RAISE(ABORT, 'audit-failure'); END`
|
||||
if driver == "mysql" {
|
||||
trigger = `CREATE TRIGGER fail_project_audit BEFORE INSERT ON audit_logs FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='audit-failure'`
|
||||
}
|
||||
if _, err := db.ExecContext(t.Context(), trigger); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
switch action {
|
||||
case "create":
|
||||
_, err = repo.CreateManagedProject(t.Context(), "root", "不能保存", "")
|
||||
case "update":
|
||||
err = repo.UpdateManagedProject(t.Context(), "root", item.ID, "不能保存", "")
|
||||
case "delete":
|
||||
err = repo.DeleteManagedProject(t.Context(), "root", item.ID)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("审计异常仍返回成功")
|
||||
}
|
||||
loaded, err := NewSQLProjectRepository(db, driver).GetProject(t.Context(), item.ID)
|
||||
if err != nil || loaded.Name != "必须保留" {
|
||||
t.Fatalf("项目未回滚:%+v,%v", loaded, err)
|
||||
}
|
||||
var count int
|
||||
if err := db.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM projects").Scan(&count); err != nil || count != 2 {
|
||||
t.Fatalf("产生部分写入:%d,%v", count, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
run("nontransactional_audit_is_rejected", func(t *testing.T, db *sql.DB, repo *SQLProjectMemberRepository, driver string) {
|
||||
if driver != "mysql" {
|
||||
t.Skip("非事务引擎边界仅适用于 MySQL")
|
||||
}
|
||||
if _, err := db.ExecContext(t.Context(), "ALTER TABLE audit_logs ENGINE=MyISAM"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := repo.CreateManagedProject(t.Context(), "root", "不能保存", ""); err == nil {
|
||||
t.Fatal("非事务审计表仍被允许")
|
||||
}
|
||||
var count int
|
||||
if err := db.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM projects").Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("非事务场景产生变更:%d,%v", count, err)
|
||||
}
|
||||
})
|
||||
run("duplicate_names_and_validation", func(t *testing.T, db *sql.DB, repo *SQLProjectMemberRepository, driver string) {
|
||||
a, err := repo.CreateManagedProject(t.Context(), "root", "同名", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := repo.CreateManagedProject(t.Context(), "root", "同名", "")
|
||||
if err != nil || a.ID == b.ID {
|
||||
t.Fatalf("重名项目没有独立标识:%v", err)
|
||||
}
|
||||
for _, name := range []string{"", strings.Repeat("中", 129), "控制\x00字符"} {
|
||||
if _, err := repo.CreateManagedProject(t.Context(), "root", name, ""); err == nil {
|
||||
t.Fatal("无效名称创建成功")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestProjectLifecycleSQLiteAuditRollback(t *testing.T) {
|
||||
for _, action := range []string{"create", "update", "delete"} {
|
||||
for _, mode := range []string{"ABORT, 'audit-failure'", "IGNORE"} {
|
||||
t.Run(action+mode, func(t *testing.T) {
|
||||
cfg := config.Default(t.TempDir()).Database
|
||||
db, err := database.Open(t.Context(), cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err := database.Migrate(t.Context(), db.DB, cfg.Driver); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.DB.Exec(`INSERT INTO users (id, username, email, password_hash, role, status) VALUES ('root', 'root', '', 'test-only', 'super_admin', 'active')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := NewSQLProjectMemberRepository(db.DB, cfg.Driver)
|
||||
item, err := repo.CreateManagedProject(t.Context(), "root", "保留原名", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.DB.Exec(`CREATE TRIGGER reject_project_audit BEFORE INSERT ON audit_logs BEGIN SELECT RAISE(` + mode + `); END`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
switch action {
|
||||
case "create":
|
||||
_, err = repo.CreateManagedProject(t.Context(), "root", "不能保存", "")
|
||||
case "update":
|
||||
err = repo.UpdateManagedProject(t.Context(), "root", item.ID, "不能保存", "")
|
||||
case "delete":
|
||||
err = repo.DeleteManagedProject(t.Context(), "root", item.ID)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("审计故障后仍然成功")
|
||||
}
|
||||
items, err := NewSQLProjectRepository(db.DB, cfg.Driver).ListProjects(t.Context())
|
||||
if err != nil || len(items) != 2 {
|
||||
t.Fatalf("事务没有回滚:%+v,%v", items, err)
|
||||
}
|
||||
loaded, err := NewSQLProjectRepository(db.DB, cfg.Driver).GetProject(t.Context(), item.ID)
|
||||
if err != nil || loaded.Name != "保留原名" {
|
||||
t.Fatalf("资料或删除未回滚:%+v,%v", loaded, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,18 +1,18 @@
|
||||
# Scert 整改任务执行清单
|
||||
|
||||
## 2026 年 9 月 28 日:项目生命周期与审计(20:21 开始,20:36 同步)
|
||||
## 2026 年 9 月 28 日:项目生命周期与审计(20:21 开始,20:47 同步)
|
||||
|
||||
需求确认:继续 P0-06/P0-07,补齐项目创建、资料修改和空项目删除的正式入口;项目不是用户分组。创建仅允许当前有效超级管理员,修改沿用项目管理员实时权限;删除仍遵守默认项目与域名、服务器、成员引用保护,不自动删除业务资源或成员,不清理现有业务数据。名称允许重名,标识由服务端生成且不可修改。所有成功变更与审计同事务,失败记录不泄露请求正文。
|
||||
|
||||
- [x] 核对上一批提交 `61652f0` 与干净工作区;浏览器工具问题不阻止本批源码整改。
|
||||
- [x] 已确认本批权限、引用及事务审计需求;修改前任务检查点 `dcede93` 已提交推送,行为正确性仍须后续测试证明。
|
||||
- [~] 项目生命周期仓储、实时判权、事务审计及失败回滚正式测试源码已开始编写,尚未执行测试;不得只在 HTTP 层先判权再写入。
|
||||
- [~] 项目创建、编辑、删除 API、失败审计和应用接线已有未提交实现;非法字段、确认、撤权、缓存及审计失败边界测试尚待补齐,不能提前认定拒绝策略通过。
|
||||
- [ ] 页面增加创建、编辑和二次确认删除,保持现有分页、角色管理及旧请求隔离。
|
||||
- [ ] 执行后端与前端回归,按真实范围补充数据库与构建证据;真实浏览器、PostgreSQL 当前实库和生产验收仍单独保留。
|
||||
- [~] 项目生命周期仓储、实时判权、事务审计及失败回滚已有实现;首轮局部测试通过,随后补充的跨数据库回滚及非事务引擎边界尚待运行,不以 HTTP 层检查代替仓储实时授权。
|
||||
- [~] 项目创建、编辑、删除 API、失败审计及真实应用接线首轮通过,包含非法字段、确认、引用、缓存和审计失败回滚;新增会话验证后停用/降权及请求取消审计测试尚待执行。
|
||||
- [~] 页面已接入创建、编辑、二次确认删除与能力提示;局部组合式逻辑及模板回归通过,完整前端与真实浏览器尚未验证。
|
||||
- [~] 首轮仓储/HTTP/应用组合共 136 个叶子通过、5 条条件跳过,退出 0;项目页面与生命周期专项 35 项通过、0 失败、0 跳过,退出 0。完整回归、MySQL 双版本、竞态及构建尚待执行;PostgreSQL 当前实库和生产验收仍单独保留。
|
||||
- [~] 已同步总清单顶部及 P0-06/P0-07 的实际进行中状态;模型和验证记录待本批实现及测试后更新,本次只提交进度文档,不夹带未验证代码、运行包、业务库、凭据或日志。
|
||||
|
||||
20:36 核对范围:工作区有 7 个未提交的后端源码/测试文件,前端生命周期操作尚未修改,本批没有新增测试通过证据。上一批正式包与回归结果不得套用于当前未提交代码。
|
||||
20:47 证据:`artifacts/project-lifecycle-20260928/api-first.jsonl` 与 `frontend-first.log`。首轮结果仅证明执行时的候选及对应测试范围;此后新增测试尚未执行,全部源码仍在工作区,未发布部署。旧批次正式包与回归结果不得套用于当前代码。
|
||||
|
||||
原始 44 项及其验收标准不缩减。删除引用冲突必须明确提示且不产生部分删除,授权能力提示不能替代服务端每次检查。
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# Scert 整改、合并与优化清单
|
||||
|
||||
## 当前进度(2026 年 9 月 28 日 20:36,项目生命周期与事务审计正在实现,尚未测试)
|
||||
## 当前进度(2026 年 9 月 28 日 20:47,项目生命周期已通过首轮局部测试,完整回归未完成)
|
||||
|
||||
### 总项状态
|
||||
|
||||
@ -14,6 +14,12 @@
|
||||
|
||||
### 本轮新增进展与下一步
|
||||
|
||||
- [~] 20:47 补充:首轮仓储/接口/真实应用接线组合测试退出 0,136 个叶子用例通过、5 条实库条件跳过;覆盖创建、更新、删除、权限拒绝、引用保护、非法输入及审计失败回滚。随后新增的会话验证后停用/降权、取消请求审计和跨数据库回滚测试尚未运行,不能套用首轮结果。
|
||||
- [~] 前端已接入创建、编辑、二次确认删除及权限能力提示;项目页面与生命周期专项 35 项通过、0 失败、0 跳过,退出 0。覆盖重复提交、切换/卸载隔离、冲突刷新、末页删除回首页等;这是组合式逻辑和模板测试,不是真实浏览器验收。
|
||||
- [ ] 当前候选的完整后端、完整前端、MySQL 双版本、竞态检测和构建尚待执行;本批源码尚未提交。下方 20:36 记录保留为历史状态,不代表当前仍未开始页面开发或从未测试。
|
||||
|
||||
#### 20:36 阶段记录(历史)
|
||||
|
||||
- [x] 核对当前工作区和执行清单:20:21 已开始项目生命周期与事务审计整改,修改前检查点为 `dcede93`;上批正式包验证记录已随 `61652f0` 提交。
|
||||
- [~] 项目创建、编辑、删除仓储和成功变更事务审计已开始编写;默认项目及资源/成员引用保护、失败审计与回滚测试源码已新增,尚未执行测试,不记为实现完成或验证通过。
|
||||
- [~] 创建、编辑、删除 API 及应用接线已有未提交修改;项目目录新增创建、管理和删除能力字段。HTTP 边界、真实应用接线及目录能力测试仍待补齐,权限和事务行为尚待验证。
|
||||
|
||||
@ -21,7 +21,7 @@
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node --test ../build/toolchain.test.mjs src/App.test.js src/views/PasswordReset/PasswordReset.test.js src/views/SetupWizard/SetupWizard.test.js src/views/SetupWizard/SetupWizardBootstrap.test.js src/views/SetupWizard/DatabaseSetup.test.js src/views/SetupWizard/SetupWizardLayout.test.js src/views/SetupWizard/BootstrapPayload.test.js src/views/Dashboard/Dashboard.test.js src/views/Dashboard/SettingsModal.test.js src/views/UserManagement/UserManagement.test.js src/views/ProjectManagement/ProjectManagement.test.js src/views/components/HelpTip/HelpTip.test.js",
|
||||
"test": "node --test ../build/toolchain.test.mjs src/App.test.js src/views/PasswordReset/PasswordReset.test.js src/views/SetupWizard/SetupWizard.test.js src/views/SetupWizard/SetupWizardBootstrap.test.js src/views/SetupWizard/DatabaseSetup.test.js src/views/SetupWizard/SetupWizardLayout.test.js src/views/SetupWizard/BootstrapPayload.test.js src/views/Dashboard/Dashboard.test.js src/views/Dashboard/SettingsModal.test.js src/views/UserManagement/UserManagement.test.js src/views/ProjectManagement/ProjectManagement.test.js src/views/ProjectManagement/ProjectLifecycle.test.js src/views/components/HelpTip/HelpTip.test.js",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
|
||||
111
frontend/src/views/ProjectManagement/ProjectLifecycle.js
Normal file
111
frontend/src/views/ProjectManagement/ProjectLifecycle.js
Normal file
@ -0,0 +1,111 @@
|
||||
import { computed, onUnmounted, ref, watch } from "vue";
|
||||
import { handleProjectSession, requestProjectAPI } from "./ProjectRequest.js";
|
||||
|
||||
export function useProjectLifecycle(project, canCreateProject, busy, refresh) {
|
||||
const projectEditor = ref(null);
|
||||
const pendingDeleteProject = ref(null);
|
||||
const savingProject = ref(false);
|
||||
const lifecycleError = ref("");
|
||||
const lifecycleNotice = ref("");
|
||||
const canEditProject = computed(() => Boolean(project.value?.can_manage_project));
|
||||
const canDeleteProject = computed(() => Boolean(project.value?.can_delete_project));
|
||||
let generation = 0;
|
||||
let refreshing = false;
|
||||
|
||||
function clear() {
|
||||
generation += 1;
|
||||
projectEditor.value = null;
|
||||
pendingDeleteProject.value = null;
|
||||
savingProject.value = false;
|
||||
lifecycleError.value = "";
|
||||
lifecycleNotice.value = "";
|
||||
}
|
||||
|
||||
function openProjectEditor(create = false) {
|
||||
if (busy.value || savingProject.value || !(create ? canCreateProject.value : canEditProject.value)) return;
|
||||
lifecycleError.value = "";
|
||||
lifecycleNotice.value = "";
|
||||
pendingDeleteProject.value = null;
|
||||
projectEditor.value = {
|
||||
create, id: create ? "" : project.value.id,
|
||||
name: create ? "" : project.value.name,
|
||||
description: create ? "" : project.value.description || "",
|
||||
};
|
||||
}
|
||||
|
||||
function requestDeleteProject() {
|
||||
if (busy.value || savingProject.value || !canDeleteProject.value) return;
|
||||
projectEditor.value = null;
|
||||
lifecycleError.value = "";
|
||||
lifecycleNotice.value = "";
|
||||
pendingDeleteProject.value = { id: project.value.id, name: project.value.name };
|
||||
}
|
||||
|
||||
function cancelProjectAction() {
|
||||
if (savingProject.value) return;
|
||||
projectEditor.value = null;
|
||||
pendingDeleteProject.value = null;
|
||||
lifecycleError.value = "";
|
||||
}
|
||||
|
||||
async function mutateProject(action, id, body) {
|
||||
if (busy.value || savingProject.value) return;
|
||||
const current = generation;
|
||||
savingProject.value = true;
|
||||
lifecycleError.value = "";
|
||||
lifecycleNotice.value = "";
|
||||
try {
|
||||
const result = await requestProjectAPI(`/api/v1/projects${action === "create" ? "" : `/${encodeURIComponent(id)}`}`, {
|
||||
method: { create: "POST", update: "PUT", delete: "DELETE" }[action],
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (current !== generation) return;
|
||||
projectEditor.value = null;
|
||||
pendingDeleteProject.value = null;
|
||||
lifecycleNotice.value = action === "create"
|
||||
? `项目已创建(ID:${result.id})。已返回项目首页,未显示时可翻页查看。`
|
||||
: action === "delete" ? "项目已删除,已返回项目首页" : "项目资料已更新";
|
||||
refreshing = true;
|
||||
await refresh(action);
|
||||
} catch (error) {
|
||||
if (current !== generation) return;
|
||||
lifecycleError.value = error.status ? handleProjectSession(error) : "请求结果未确认,请刷新项目后核对,勿重复提交";
|
||||
if (error.status === 403 || error.status === 409) {
|
||||
projectEditor.value = null;
|
||||
pendingDeleteProject.value = null;
|
||||
refreshing = true;
|
||||
await refresh("conflict");
|
||||
}
|
||||
} finally {
|
||||
refreshing = false;
|
||||
if (current === generation) savingProject.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function saveProject() {
|
||||
const form = projectEditor.value;
|
||||
if (!form || !(form.create ? canCreateProject.value : canEditProject.value && form.id === project.value?.id)) return;
|
||||
const name = form.name.trim();
|
||||
const description = form.description.trim();
|
||||
if (!name || [...name].length > 128 || [...description].length > 4096 || /[\u0000-\u001f\u007f]/.test(name)) {
|
||||
lifecycleError.value = "请填写有效项目名称(最多 128 字),说明最多 4096 字";
|
||||
return;
|
||||
}
|
||||
return mutateProject(form.create ? "create" : "update", form.id, { name, description });
|
||||
}
|
||||
|
||||
function confirmDeleteProject() {
|
||||
const target = pendingDeleteProject.value;
|
||||
if (!target || !canDeleteProject.value || target.id !== project.value?.id) return;
|
||||
return mutateProject("delete", target.id, { confirmation: target.id });
|
||||
}
|
||||
|
||||
watch(project, () => { if (!refreshing) clear(); }, { flush: "sync" });
|
||||
onUnmounted(clear);
|
||||
|
||||
return {
|
||||
projectEditor, pendingDeleteProject, savingProject, lifecycleError, lifecycleNotice,
|
||||
canEditProject, canDeleteProject, openProjectEditor, requestDeleteProject,
|
||||
cancelProjectAction, saveProject, confirmDeleteProject,
|
||||
};
|
||||
}
|
||||
173
frontend/src/views/ProjectManagement/ProjectLifecycle.test.js
Normal file
173
frontend/src/views/ProjectManagement/ProjectLifecycle.test.js
Normal file
@ -0,0 +1,173 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { ref } from "vue";
|
||||
import { mountComposable, deferred } from "../../test-support/mount.js";
|
||||
import { useProjectLifecycle } from "./ProjectLifecycle.js";
|
||||
import { useProjectManagement } from "./ProjectManagement.js";
|
||||
|
||||
const projectA = { id: "project-a", name: "项目甲", description: "说明", can_manage_project: true, can_delete_project: true, can_manage_members: false };
|
||||
const projectB = { ...projectA, id: "project-b", name: "项目乙" };
|
||||
|
||||
async function setup(t, fetcher, refresh = async () => {}) {
|
||||
const project = ref({ ...projectA });
|
||||
const canCreate = ref(true);
|
||||
const busy = ref(false);
|
||||
const mounted = await mountComposable(t, () => useProjectLifecycle(project, canCreate, busy, refresh), fetcher);
|
||||
return { ...mounted, project, canCreate, busy };
|
||||
}
|
||||
|
||||
test("创建项目仅发送资料,编辑及删除使用固定项目标识", async (t) => {
|
||||
const calls = [];
|
||||
const refreshed = [];
|
||||
const { state } = await setup(t, async (path, options) => {
|
||||
calls.push([path, options]);
|
||||
return options.method === "DELETE" ? new Response(null, { status: 204 }) : Response.json({ id: "created" });
|
||||
}, async (action) => refreshed.push(action));
|
||||
state.openProjectEditor(true);
|
||||
state.projectEditor.value.name = " 新项目 ";
|
||||
state.projectEditor.value.description = " 新说明 ";
|
||||
await state.saveProject();
|
||||
assert.deepEqual(JSON.parse(calls[0][1].body), { name: "新项目", description: "新说明" });
|
||||
assert.equal(calls[0][0], "/api/v1/projects");
|
||||
assert.equal(calls[0][1].method, "POST");
|
||||
assert.match(state.lifecycleNotice.value, /created/);
|
||||
state.openProjectEditor(false);
|
||||
state.projectEditor.value.name = "修改项目";
|
||||
await state.saveProject();
|
||||
assert.equal(calls[1][0], "/api/v1/projects/project-a");
|
||||
assert.equal(calls[1][1].method, "PUT");
|
||||
state.requestDeleteProject();
|
||||
assert.equal(calls.length, 2);
|
||||
await state.confirmDeleteProject();
|
||||
assert.equal(calls[2][1].method, "DELETE");
|
||||
assert.deepEqual(JSON.parse(calls[2][1].body), { confirmation: "project-a" });
|
||||
assert.deepEqual(refreshed, ["create", "update", "delete"]);
|
||||
assert.ok(calls.every(([, options]) => options.credentials === "include" && options.cache === "no-store"));
|
||||
});
|
||||
|
||||
test("生命周期权限和成员保存状态阻止发送变更", async (t) => {
|
||||
const { state, project, canCreate, busy } = await setup(t, () => assert.fail("不应发送请求"));
|
||||
canCreate.value = false;
|
||||
state.openProjectEditor(true);
|
||||
assert.equal(state.projectEditor.value, null);
|
||||
project.value = { ...projectA, can_manage_project: false, can_delete_project: false };
|
||||
state.openProjectEditor(false);
|
||||
state.requestDeleteProject();
|
||||
assert.equal(state.projectEditor.value, null);
|
||||
assert.equal(state.pendingDeleteProject.value, null);
|
||||
project.value = { ...projectA };
|
||||
busy.value = true;
|
||||
state.openProjectEditor(false);
|
||||
assert.equal(state.projectEditor.value, null);
|
||||
});
|
||||
|
||||
test("项目名称校验、二次确认取消和切换项目清理", async (t) => {
|
||||
const { state, project } = await setup(t, () => assert.fail("不应发送请求"));
|
||||
state.openProjectEditor(true);
|
||||
state.projectEditor.value.name = " ";
|
||||
await state.saveProject();
|
||||
assert.match(state.lifecycleError.value, /项目名称/);
|
||||
state.cancelProjectAction();
|
||||
assert.equal(state.projectEditor.value, null);
|
||||
state.requestDeleteProject();
|
||||
state.cancelProjectAction();
|
||||
await state.confirmDeleteProject();
|
||||
state.requestDeleteProject();
|
||||
project.value = { ...projectB };
|
||||
assert.equal(state.pendingDeleteProject.value, null);
|
||||
await state.confirmDeleteProject();
|
||||
});
|
||||
|
||||
test("重复提交、切换及卸载后的迟到结果不修改新页面", async (t) => {
|
||||
for (const change of ["switch", "unmount"]) {
|
||||
await t.test(change, async (t) => {
|
||||
const pending = deferred();
|
||||
let calls = 0;
|
||||
let refreshes = 0;
|
||||
const { state, project, unmount } = await setup(t, () => { calls += 1; return pending.promise; }, async () => { refreshes += 1; });
|
||||
state.requestDeleteProject();
|
||||
const first = state.confirmDeleteProject();
|
||||
await state.confirmDeleteProject();
|
||||
assert.equal(calls, 1);
|
||||
if (change === "switch") project.value = { ...projectB };
|
||||
else unmount();
|
||||
pending.resolve(new Response(null, { status: 204 }));
|
||||
await first;
|
||||
assert.equal(refreshes, 0);
|
||||
assert.equal(state.lifecycleNotice.value, "");
|
||||
assert.equal(state.savingProject.value, false);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("拒绝及引用冲突刷新权限且不显示删除成功", async (t) => {
|
||||
for (const status of [403, 409]) {
|
||||
await t.test(String(status), async (t) => {
|
||||
let refreshes = 0;
|
||||
const { state, project } = await setup(t, async () => Response.json({ error: "存在引用或权限失效", request_id: "request-test" }, { status }), async () => {
|
||||
refreshes += 1;
|
||||
project.value = { ...projectA, can_manage_project: false, can_delete_project: false };
|
||||
});
|
||||
state.requestDeleteProject();
|
||||
await state.confirmDeleteProject();
|
||||
assert.equal(refreshes, 1);
|
||||
assert.equal(state.lifecycleNotice.value, "");
|
||||
assert.match(state.lifecycleError.value, /request-test/);
|
||||
assert.equal(state.canDeleteProject.value, false);
|
||||
assert.equal(state.pendingDeleteProject.value, null);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("会话过期跳登录,网络中断明确结果未确认", async (t) => {
|
||||
let mode = "auth";
|
||||
const { state } = await setup(t, async () => {
|
||||
if (mode === "network") throw new TypeError("network");
|
||||
return Response.json({ error: "登录失效" }, { status: 401 });
|
||||
});
|
||||
const redirects = [];
|
||||
window.location.assign = (path) => redirects.push(path);
|
||||
state.requestDeleteProject();
|
||||
await state.confirmDeleteProject();
|
||||
assert.deepEqual(redirects, ["/login"]);
|
||||
mode = "network";
|
||||
await state.confirmDeleteProject();
|
||||
assert.match(state.lifecycleError.value, /结果未确认/);
|
||||
assert.equal(state.lifecycleNotice.value, "");
|
||||
});
|
||||
|
||||
test("删除末页项目后回到首页,刷新期间不可切换或重复操作", async (t) => {
|
||||
let deleted = false;
|
||||
const pending = deferred();
|
||||
const { state } = await mountComposable(t, useProjectManagement, async (path, options) => {
|
||||
if (options.method === "DELETE") return pending.promise;
|
||||
const second = new URL(path, "http://local").searchParams.get("after");
|
||||
return Response.json({ items: deleted || !second ? [projectA] : [projectB], can_create_project: true, has_more: !deleted && !second, next_cursor: "project-a" });
|
||||
});
|
||||
await state.nextProjectPage();
|
||||
assert.equal(state.selectedProject.value.id, "project-b");
|
||||
state.requestDeleteProject();
|
||||
const saving = state.confirmDeleteProject();
|
||||
state.selectProject("project-a");
|
||||
await state.previousProjectPage();
|
||||
assert.equal(state.projectPage.value, 1);
|
||||
deleted = true;
|
||||
pending.resolve(new Response(null, { status: 204 }));
|
||||
await saving;
|
||||
assert.equal(state.projectPage.value, 0);
|
||||
assert.equal(state.selectedProject.value.id, "project-a");
|
||||
assert.match(state.lifecycleNotice.value, /已删除/);
|
||||
assert.equal(state.savingProject.value, false);
|
||||
});
|
||||
|
||||
test("新建项目未在首页出现时保留明确成功标识,不伪造目录条目", async (t) => {
|
||||
const { state } = await mountComposable(t, useProjectManagement, async (_path, options) => options.method === "POST"
|
||||
? Response.json({ id: "outside-first-page" }, { status: 201 })
|
||||
: Response.json({ items: [projectA], can_create_project: true }));
|
||||
state.openProjectEditor(true);
|
||||
state.projectEditor.value.name = "末页项目";
|
||||
await state.saveProject();
|
||||
assert.deepEqual(state.projects.value.map((item) => item.id), ["project-a"]);
|
||||
assert.match(state.lifecycleNotice.value, /outside-first-page/);
|
||||
assert.match(state.lifecycleNotice.value, /翻页/);
|
||||
});
|
||||
@ -45,6 +45,7 @@
|
||||
|
||||
.project-management button,
|
||||
.project-management input,
|
||||
.project-management textarea,
|
||||
.project-management select {
|
||||
min-height: 36px;
|
||||
padding: 7px 10px;
|
||||
@ -209,6 +210,45 @@
|
||||
background: #fff4df;
|
||||
}
|
||||
|
||||
.project-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.project-editor {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(160px, 1fr) minmax(240px, 2fr);
|
||||
align-items: end;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
padding: 16px;
|
||||
border: 1px solid #dfe6f0;
|
||||
border-radius: 10px;
|
||||
background: #f8faff;
|
||||
}
|
||||
|
||||
.project-editor h3,
|
||||
.project-editor .project-actions {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.project-editor label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.project-editor textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.project-management .project-danger {
|
||||
color: #a12c3b;
|
||||
border-color: #e2b8c0;
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.project-layout {
|
||||
grid-template-columns: 1fr;
|
||||
@ -227,4 +267,8 @@
|
||||
.project-grant-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.project-editor {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { onMounted, onUnmounted, ref } from "vue";
|
||||
import { computed, onMounted, onUnmounted, ref } from "vue";
|
||||
import { handleProjectSession, pageSizes, requestProjectAPI } from "./ProjectRequest.js";
|
||||
import { useProjectMembers } from "./ProjectMembers.js";
|
||||
import { useProjectLifecycle } from "./ProjectLifecycle.js";
|
||||
|
||||
export function useProjectManagement() {
|
||||
const projects = ref([]);
|
||||
@ -12,11 +13,15 @@ export function useProjectManagement() {
|
||||
const projectPage = ref(0);
|
||||
const projectHasMore = ref(false);
|
||||
const projectNextCursor = ref("");
|
||||
const canCreateProject = ref(false);
|
||||
const members = useProjectMembers(selectedProject);
|
||||
const lifecycle = useProjectLifecycle(selectedProject, canCreateProject,
|
||||
computed(() => loadingProjects.value || members.savingMember.value), refreshAfterMutation);
|
||||
let generation = 0;
|
||||
let controller;
|
||||
|
||||
async function loadProjects() {
|
||||
async function loadProjects(options = {}) {
|
||||
if (members.savingMember.value || (lifecycle.savingProject.value && options.internal !== true)) return;
|
||||
const current = ++generation;
|
||||
controller?.abort();
|
||||
controller = new AbortController();
|
||||
@ -27,6 +32,7 @@ export function useProjectManagement() {
|
||||
selectedProject.value = null;
|
||||
projectHasMore.value = false;
|
||||
projectNextCursor.value = "";
|
||||
canCreateProject.value = false;
|
||||
const query = new URLSearchParams({
|
||||
page_size: String(projectPageSize.value),
|
||||
after: projectCursors.value[projectPage.value],
|
||||
@ -35,6 +41,7 @@ export function useProjectManagement() {
|
||||
const result = await requestProjectAPI(`/api/v1/projects?${query}`, { signal: controller.signal });
|
||||
if (current !== generation) return;
|
||||
projects.value = result.items || [];
|
||||
canCreateProject.value = Boolean(result.can_create_project);
|
||||
projectHasMore.value = Boolean(result.has_more && result.next_cursor);
|
||||
projectNextCursor.value = result.next_cursor || "";
|
||||
selectedProject.value = projects.value.find((item) => item.id === selectedID) || projects.value[0] || null;
|
||||
@ -45,13 +52,21 @@ export function useProjectManagement() {
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAfterMutation(action) {
|
||||
if (action === "create" || action === "delete") {
|
||||
projectPage.value = 0;
|
||||
projectCursors.value = [""];
|
||||
}
|
||||
await loadProjects({ internal: true });
|
||||
}
|
||||
|
||||
function selectProject(id) {
|
||||
if (loadingProjects.value || selectedProject.value?.id === id) return;
|
||||
if (loadingProjects.value || lifecycle.savingProject.value || members.savingMember.value || selectedProject.value?.id === id) return;
|
||||
selectedProject.value = projects.value.find((item) => item.id === id) || null;
|
||||
}
|
||||
|
||||
async function changeProjectPageSize(value) {
|
||||
if (!pageSizes.includes(Number(value))) return;
|
||||
if (lifecycle.savingProject.value || members.savingMember.value || !pageSizes.includes(Number(value))) return;
|
||||
projectPageSize.value = Number(value);
|
||||
projectPage.value = 0;
|
||||
projectCursors.value = [""];
|
||||
@ -59,14 +74,14 @@ export function useProjectManagement() {
|
||||
}
|
||||
|
||||
async function nextProjectPage() {
|
||||
if (loadingProjects.value || !projectHasMore.value) return;
|
||||
if (loadingProjects.value || lifecycle.savingProject.value || members.savingMember.value || !projectHasMore.value) return;
|
||||
projectCursors.value = [...projectCursors.value.slice(0, projectPage.value + 1), projectNextCursor.value];
|
||||
projectPage.value += 1;
|
||||
await loadProjects();
|
||||
}
|
||||
|
||||
async function previousProjectPage() {
|
||||
if (loadingProjects.value || projectPage.value === 0) return;
|
||||
if (loadingProjects.value || lifecycle.savingProject.value || members.savingMember.value || projectPage.value === 0) return;
|
||||
projectPage.value -= 1;
|
||||
await loadProjects();
|
||||
}
|
||||
@ -78,7 +93,7 @@ export function useProjectManagement() {
|
||||
});
|
||||
|
||||
return {
|
||||
...members, projects, selectedProject, loadingProjects, projectError,
|
||||
...members, ...lifecycle, canCreateProject, projects, selectedProject, loadingProjects, projectError,
|
||||
projectPageSize, projectPage, projectHasMore, pageSizes,
|
||||
loadProjects, selectProject, changeProjectPageSize, nextProjectPage, previousProjectPage,
|
||||
};
|
||||
|
||||
@ -9,6 +9,9 @@ const {
|
||||
memberNotice, canManageMembers, memberPageSize, memberPage, memberHasMore,
|
||||
memberForm, projectRoles, pendingRevoke, loadMembers, changeMemberPageSize,
|
||||
nextMemberPage, previousMemberPage, grantMember, requestRevoke, confirmRevoke, roleLabel,
|
||||
canCreateProject, projectEditor, pendingDeleteProject, savingProject,
|
||||
lifecycleError, lifecycleNotice, canEditProject, canDeleteProject,
|
||||
openProjectEditor, requestDeleteProject, cancelProjectAction, saveProject, confirmDeleteProject,
|
||||
} = useProjectManagement();
|
||||
</script>
|
||||
|
||||
@ -16,10 +19,34 @@ const {
|
||||
<section class="project-management" aria-label="项目与成员">
|
||||
<header class="project-heading">
|
||||
<h2>项目与成员</h2>
|
||||
<button type="button" :disabled="loadingProjects || savingMember" @click="loadProjects">刷新项目</button>
|
||||
<div class="project-actions">
|
||||
<button v-if="canCreateProject" class="project-primary" type="button" :disabled="loadingProjects || savingMember || savingProject" @click="openProjectEditor(true)">创建项目</button>
|
||||
<button type="button" :disabled="loadingProjects || savingMember || savingProject" @click="loadProjects">刷新项目</button>
|
||||
</div>
|
||||
</header>
|
||||
<p v-if="projectError" class="project-error" role="alert">{{ projectError }}</p>
|
||||
<div class="project-layout">
|
||||
<p v-if="lifecycleError" class="project-error" role="alert">{{ lifecycleError }}</p>
|
||||
<p v-if="lifecycleNotice" class="project-notice" role="status">{{ lifecycleNotice }}</p>
|
||||
<form v-if="projectEditor" class="project-editor" aria-label="项目资料" @submit.prevent="saveProject">
|
||||
<h3>{{ projectEditor.create ? "创建项目" : "编辑项目" }}</h3>
|
||||
<label>项目名称
|
||||
<input v-model="projectEditor.name" required maxlength="128" autocomplete="off" :disabled="savingProject" />
|
||||
</label>
|
||||
<label>项目说明
|
||||
<textarea v-model="projectEditor.description" maxlength="4096" rows="2" :disabled="savingProject"></textarea>
|
||||
</label>
|
||||
<div class="project-actions">
|
||||
<button class="project-primary" type="submit" :disabled="savingProject || loadingProjects || savingMember">{{ savingProject ? "正在保存……" : "保存项目" }}</button>
|
||||
<button type="button" :disabled="savingProject" @click="cancelProjectAction">取消</button>
|
||||
</div>
|
||||
</form>
|
||||
<div v-if="pendingDeleteProject" class="project-confirm" role="alert">
|
||||
<span>确认删除项目“{{ pendingDeleteProject.name }}”({{ pendingDeleteProject.id }})?</span>
|
||||
<span class="project-help" tabindex="0" title="默认项目不能删除;存在域名、服务器、绑定或成员时会拒绝删除,不会自动删除关联资源。">ⓘ</span>
|
||||
<button class="project-danger" type="button" :disabled="savingProject || loadingProjects || savingMember" @click="confirmDeleteProject">{{ savingProject ? "正在删除……" : "确认删除项目" }}</button>
|
||||
<button type="button" :disabled="savingProject" @click="cancelProjectAction">取消</button>
|
||||
</div>
|
||||
<div class="project-layout" :inert="savingProject" :aria-busy="savingProject">
|
||||
<aside class="project-directory" aria-label="可见项目">
|
||||
<div class="project-toolbar">
|
||||
<h3>我的项目</h3>
|
||||
@ -56,7 +83,11 @@ const {
|
||||
<h3 :title="selectedProject.description">{{ selectedProject.name }}</h3>
|
||||
<small :title="selectedProject.id">项目 ID:{{ selectedProject.id }}</small>
|
||||
</div>
|
||||
<button v-if="canManageMembers" type="button" :disabled="loadingMembers || savingMember" @click="loadMembers">刷新成员</button>
|
||||
<div class="project-actions">
|
||||
<button v-if="canEditProject" type="button" :disabled="loadingProjects || savingMember || savingProject" @click="openProjectEditor(false)">编辑项目</button>
|
||||
<button v-if="canDeleteProject" class="project-danger" type="button" :disabled="loadingProjects || savingMember || savingProject" @click="requestDeleteProject">删除项目</button>
|
||||
<button v-if="canManageMembers" type="button" :disabled="loadingMembers || savingMember || savingProject" @click="loadMembers">刷新成员</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="memberError" class="project-error" role="alert">{{ memberError }}</p>
|
||||
<p v-if="memberNotice" class="project-notice" role="status">{{ memberNotice }}</p>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user