开发检查点:收尾项目生命周期并接入资源读取隔离,待统一测试

This commit is contained in:
xuans 2026-09-28 21:20:20 +08:00
parent fd767b23c3
commit 709c1ef063
27 changed files with 785 additions and 68 deletions

View File

@ -158,6 +158,7 @@ func NewWithConfigPath(ctx context.Context, cfg config.Config, configPath string
ProjectMembers: repository.NewSQLProjectMemberRepository(db.DB, runtimeDatabaseConfig.Driver),
ProjectDirectory: repository.NewSQLProjectMemberRepository(db.DB, runtimeDatabaseConfig.Driver),
ProjectLifecycle: repository.NewSQLProjectMemberRepository(db.DB, runtimeDatabaseConfig.Driver),
ProjectResources: 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) {

View File

@ -37,6 +37,14 @@ func TestApplicationWiresProjectLifecycleAPI(t *testing.T) {
}
login := request(http.MethodPost, "/api/v1/auth/login", `{"identifier":"admin","password":"Test1234!"}`, 200)
cookies = login.Result().Cookies()
for _, path := range []string{"/api/v1/domains", "/api/v1/servers", "/api/v1/certificates"} {
resources := request(http.MethodGet, path, "", http.StatusOK)
if !strings.Contains(resources.Body.String(), `"items":[]`) || resources.Header().Get("Cache-Control") != "private, no-store" {
t.Fatalf("实际应用未接入空资源列表与缓存隔离:%s,%s", path, resources.Body.String())
}
}
request(http.MethodGet, "/api/v1/servers/missing", "", http.StatusForbidden)
request(http.MethodGet, "/api/v1/certificates/missing", "", http.StatusForbidden)
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 == "" {

View File

@ -13,6 +13,10 @@ const DefaultProjectID = "default"
var projectIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,35}$`)
func ValidProjectID(id string) bool {
return projectIDPattern.MatchString(id)
}
type Project struct {
ID string `json:"id"`
Name string `json:"name"`
@ -41,7 +45,7 @@ func (p Project) Normalize() Project {
}
func (p Project) Validate() error {
if !projectIDPattern.MatchString(p.ID) {
if !ValidProjectID(p.ID) {
return errors.New("项目 ID 必须为 1~36 位小写字母、数字、连字符或下划线,并以字母或数字开头")
}
if !utf8.ValidString(p.Name) || strings.TrimSpace(p.Name) == "" || utf8.RuneCountInString(p.Name) > 128 {

View File

@ -22,6 +22,19 @@ func TestNewProject(t *testing.T) {
}
}
func TestValidProjectID(t *testing.T) {
for _, id := range []string{DefaultProjectID, "a", "1", "project_a-1", strings.Repeat("a", 36)} {
if !ValidProjectID(id) {
t.Fatalf("拒绝合法项目标识:%q", id)
}
}
for _, id := range []string{"", "UPPERCASE", strings.Repeat("a", 37), " project", "project ", "_project", "../project", "项目"} {
if ValidProjectID(id) {
t.Fatalf("接受无效项目标识:%q", id)
}
}
}
func TestProjectValidation(t *testing.T) {
cases := []struct {
name string

View File

@ -57,7 +57,8 @@ func TestAuthSessionsSupportOptionalEmail(t *testing.T) {
if !payload.Authenticated || payload.Email != "" || payload.Role != role || payload.IsAdmin != (role == repository.UserRoleAdmin) {
t.Fatalf("unexpected session identity: %+v", payload)
}
request := httptest.NewRequest(http.MethodGet, "/api/v1/domains", nil)
// 全局管理角色边界使用系统设置;域名读取现按项目成员身份判定。
request := httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil)
request.AddCookie(cookies[0])
access := httptest.NewRecorder()
server.Handler().ServeHTTP(access, request)

View File

@ -30,13 +30,16 @@ type certificatePathRequest struct {
}
func (s *Server) listCertificates(c *gin.Context) {
if s.repository == nil || s.currentCertificate == nil {
if !s.requireProjectResourceReader(c) {
return
}
if s.currentCertificate == nil {
s.writeError(c, http.StatusNotImplemented, "证书管理接口尚未配置", errors.New("证书服务未配置"))
return
}
items, err := s.repository.List(c.Request.Context())
items, err := s.projectResources.ListVisibleDomains(c.Request.Context(), c.GetString(authenticatedActorKey))
if err != nil {
s.writeError(c, http.StatusInternalServerError, "查询域名证书失败", err)
s.writeProjectResourceError(c, err)
return
}
result := make([]gin.H, 0, len(items))
@ -57,6 +60,9 @@ func (s *Server) listCertificates(c *gin.Context) {
}
func (s *Server) getCertificate(c *gin.Context) {
if !s.requireVisibleCertificateDomain(c) {
return
}
if s.currentCertificate == nil {
s.writeError(c, http.StatusNotImplemented, "证书管理接口尚未配置", errors.New("证书服务未配置"))
return
@ -70,6 +76,9 @@ func (s *Server) getCertificate(c *gin.Context) {
}
func (s *Server) listCertificateVersions(c *gin.Context) {
if !s.requireVisibleCertificateDomain(c) {
return
}
if s.currentCertificate == nil {
s.writeError(c, http.StatusNotImplemented, "证书管理接口尚未配置", errors.New("证书服务未配置"))
return

View File

@ -49,7 +49,11 @@ func TestCertificateReadAPIs(t *testing.T) {
versions: []certificate.SavedResource{{Version: "20260827-120000.000000000"}},
}
repo := &stubDomainRepository{items: []domain.Domain{{ID: "domain-1", Name: "example.com", CertificateType: domain.CertificateSAN}}}
server := New(Dependencies{Config: readyTestConfig(t.TempDir()), Repository: repo, CurrentCertificate: reader})
repo.loaded = repo.items[0]
server := New(Dependencies{
Config: readyTestConfig(t.TempDir()), Repository: repo, CurrentCertificate: reader,
ProjectResources: projectResourceReaderStub{domains: repo},
})
list := httptest.NewRecorder()
serveAdminHTTP(server, list, httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil))
@ -79,6 +83,7 @@ func TestCertificateListHidesReaderErrors(t *testing.T) {
Config: readyTestConfig(t.TempDir()),
Repository: repo,
CurrentCertificate: reader,
ProjectResources: projectResourceReaderStub{domains: repo},
})
response := httptest.NewRecorder()

View File

@ -77,7 +77,7 @@ func TestAdminAPIAcceptsAuthenticatedSessionAndRejectsCrossOrigin(t *testing.T)
t.Fatal("登录响应未返回会话 Cookie")
}
authenticatedRequest := httptest.NewRequest(http.MethodGet, "/api/v1/domains", nil)
authenticatedRequest := httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil)
authenticatedRequest.AddCookie(cookie[0])
authenticatedResponse := httptest.NewRecorder()
server.Handler().ServeHTTP(authenticatedResponse, authenticatedRequest)
@ -85,7 +85,7 @@ func TestAdminAPIAcceptsAuthenticatedSessionAndRejectsCrossOrigin(t *testing.T)
t.Fatalf("登录后访问管理接口失败:%d %s", authenticatedResponse.Code, authenticatedResponse.Body.String())
}
crossOriginRequest := httptest.NewRequest(http.MethodGet, "/api/v1/domains", nil)
crossOriginRequest := httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil)
crossOriginRequest.AddCookie(cookie[0])
crossOriginRequest.Host = "127.0.0.1:5200"
crossOriginRequest.Header.Set("Origin", "https://malicious.example")
@ -108,7 +108,7 @@ func TestAdminAPIRejectsAuthenticatedRegularUser(t *testing.T) {
cfg.Admin.PasswordHash = string(hash)
server := New(Dependencies{Config: cfg, Repository: &stubDomainRepository{}})
cookie := httpUserSessionCookie(server, "user-1", repository.UserRoleUser)
response := serveUserRequest(server, cookie, http.MethodGet, "/api/v1/domains", "")
response := serveUserRequest(server, cookie, http.MethodGet, "/api/v1/settings", "")
if response.Code != http.StatusForbidden {
t.Fatalf("普通用户访问管理接口应返回 403,实际为 %d:%s", response.Code, response.Body.String())
}

View File

@ -24,7 +24,7 @@ func (s *Server) projectLifecycleReady(c *gin.Context, creating bool) bool {
s.writeError(c, http.StatusServiceUnavailable, "项目管理服务尚未就绪", nil)
return false
}
if c.Request.URL.RawQuery != "" || (!creating && !validMemberIdentifier(c.Param("projectID"))) {
if c.Request.URL.RawQuery != "" || (!creating && !domain.ValidProjectID(c.Param("projectID"))) {
s.writeError(c, http.StatusBadRequest, "项目请求参数无效", nil)
return false
}
@ -119,7 +119,7 @@ func (s *Server) projectFailureAudit(action string) gin.HandlerFunc {
return
}
projectID := c.Param("projectID")
if !validMemberIdentifier(projectID) {
if !domain.ValidProjectID(projectID) {
projectID = ""
}
ctx, cancel := context.WithTimeout(context.WithoutCancel(c.Request.Context()), 3*time.Second)

View File

@ -27,7 +27,7 @@ func (s *Server) projectMemberRequestReady(c *gin.Context) bool {
s.writeError(c, http.StatusServiceUnavailable, "项目成员服务尚未就绪", nil)
return false
}
if !validMemberIdentifier(c.Param("projectID")) {
if !domain.ValidProjectID(c.Param("projectID")) {
s.writeError(c, http.StatusBadRequest, "项目标识无效", nil)
return false
}

View File

@ -6,6 +6,7 @@ import (
"github.com/gin-gonic/gin"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
"git.pcux.cn/xuans/Scert/backend/internal/repository"
)
@ -17,7 +18,7 @@ func (s *Server) projectMemberFailureAudit(action string) gin.HandlerFunc {
return
}
projectID := c.Param("projectID")
if !validMemberIdentifier(projectID) {
if !domain.ValidProjectID(projectID) {
projectID = ""
}
target := c.GetString(projectMemberTargetKey)

View File

@ -48,7 +48,10 @@ 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, ProjectLifecycle: f.members, Audit: f.audit})
f.server = New(Dependencies{
Config: cfg, Users: users, ProjectMembers: f.members, ProjectDirectory: f.members,
ProjectLifecycle: f.members, ProjectResources: 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},

View File

@ -0,0 +1,49 @@
package httpapi
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"git.pcux.cn/xuans/Scert/backend/internal/repository"
)
func (s *Server) registerProjectResourceRoutes() {
api := s.router.Group("/api/v1", privateProjectResponse, s.requireAuthenticated)
api.GET("/domains", s.listDomains)
api.GET("/servers", s.listServers)
api.GET("/servers/:id", s.getServer)
api.GET("/domains/:id/servers", s.listDomainServers)
api.GET("/certificates", s.listCertificates)
api.GET("/certificates/:id", s.getCertificate)
api.GET("/domains/:id/certificate/versions", s.listCertificateVersions)
}
func (s *Server) requireProjectResourceReader(c *gin.Context) bool {
if s.projectResources == nil {
s.writeError(c, http.StatusServiceUnavailable, "项目资源服务尚未就绪", nil)
return false
}
return true
}
func (s *Server) writeProjectResourceError(c *gin.Context, err error) {
if errors.Is(err, repository.ErrProjectAccessDenied) {
// 无权限和不存在使用相同响应,不泄露其他项目的资源是否存在。
s.writeError(c, http.StatusForbidden, "无权读取此项目资源", nil)
return
}
s.writeError(c, http.StatusInternalServerError, "读取项目资源失败,请稍后重试", nil)
}
func (s *Server) requireVisibleCertificateDomain(c *gin.Context) bool {
if !s.requireProjectResourceReader(c) {
return false
}
if _, err := s.projectResources.GetVisibleDomain(c.Request.Context(), c.GetString(authenticatedActorKey), c.Param("id")); err != nil {
s.writeProjectResourceError(c, err)
return false
}
return true
}

View File

@ -0,0 +1,190 @@
package httpapi
import (
"context"
"encoding/json"
"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"
)
type resourceCertificateReader struct {
reads []string
}
func (r *resourceCertificateReader) Current(_ context.Context, id string) (certificate.IssueResult, error) {
r.reads = append(r.reads, id)
return certificate.IssueResult{DomainID: id}, nil
}
func (r *resourceCertificateReader) ListVersions(_ context.Context, id string) ([]certificate.SavedResource, error) {
r.reads = append(r.reads, id)
return []certificate.SavedResource{}, nil
}
func newProjectResourceAPIFixture(t *testing.T) (projectMemberAPIFixture, *resourceCertificateReader) {
t.Helper()
f := newProjectMemberAPIFixture(t)
domains := repository.NewSQLDomainRepository(f.db)
servers := repository.NewSQLServerRepository(f.db)
for _, suffix := range []string{"a", "b"} {
item, err := domain.NewDomain(suffix+".example.test", domain.CertificateSingle, nil)
if err != nil {
t.Fatal(err)
}
item.ID, item.ProjectID = "domain-"+suffix, "project-"+suffix
if err := domains.Create(t.Context(), item); err != nil {
t.Fatal(err)
}
server := deployment.Server{
ResourceScope: domain.ResourceScope{ProjectID: item.ProjectID},
ID: "server-" + suffix, Name: suffix + " 服务器", Address: "192.0.2.10", Port: 22, Username: "deploy",
PrivateKeyRef: "secret://ssh/fixture", HostKeyFingerprint: "SHA256:test",
CertificatePath: "/ssl/cert.pem", PrivateKeyPath: "/ssl/key.pem", WebServer: deployment.WebServerNginx,
}
if err := servers.CreateServer(t.Context(), server); err != nil {
t.Fatal(err)
}
if err := servers.ReplaceDomainServers(t.Context(), item.ID, []string{server.ID}); err != nil {
t.Fatal(err)
}
}
reader := &resourceCertificateReader{}
f.server.repository, f.server.servers, f.server.currentCertificate = domains, servers, reader
return f, reader
}
func TestProjectResourceAPIListsUseProjectPermissions(t *testing.T) {
f, reader := newProjectResourceAPIFixture(t)
for _, tc := range []struct {
actor string
count int
}{
{"primary-super-admin", 2}, {"manager", 1}, {"viewer", 1}, {"global-admin", 0}, {"target", 0},
} {
for _, path := range []string{"/api/v1/domains", "/api/v1/servers", "/api/v1/certificates"} {
reader.reads = nil
response := serveUserRequest(f.server, f.cookies[tc.actor], http.MethodGet, path, "")
var page struct {
Items []json.RawMessage `json:"items"`
}
if response.Code != http.StatusOK || json.Unmarshal(response.Body.Bytes(), &page) != nil || page.Items == nil || len(page.Items) != tc.count {
t.Fatalf("可见资源范围错误:%s,%s,%d,%s", tc.actor, path, response.Code, response.Body.String())
}
if response.Header().Get("Cache-Control") != "private, no-store" || !strings.Contains(response.Header().Get("Vary"), "Cookie") {
t.Fatalf("资源响应缺少身份缓存隔离:%v", response.Header())
}
if tc.actor != "primary-super-admin" && strings.Contains(response.Body.String(), "project-b") {
t.Fatalf("泄露其他项目:%s", response.Body.String())
}
if path == "/api/v1/certificates" && len(reader.reads) != tc.count {
t.Fatalf("证书读取器访问了无权限域名:%+v", reader.reads)
}
}
}
}
func TestProjectResourceAPIDetailsHideExistenceAndGateFiles(t *testing.T) {
f, reader := newProjectResourceAPIFixture(t)
for _, suffix := range []string{"a", "b", "missing"} {
for _, path := range []string{
"/api/v1/servers/server-" + suffix,
"/api/v1/certificates/domain-" + suffix,
"/api/v1/domains/domain-" + suffix + "/certificate/versions",
"/api/v1/domains/domain-" + suffix + "/servers",
} {
reader.reads = nil
response := serveUserRequest(f.server, f.cookies["viewer"], http.MethodGet, path, "")
want := http.StatusForbidden
if suffix == "a" {
want = http.StatusOK
}
if response.Code != want {
t.Fatalf("资源详情权限错误:%s,%d,%s", path, response.Code, response.Body.String())
}
if suffix != "a" && (len(reader.reads) != 0 || !strings.Contains(response.Body.String(), "无权读取此项目资源")) {
t.Fatalf("拒绝前访问了证书文件或泄露资源:%s,%+v", response.Body.String(), reader.reads)
}
}
}
}
type resourceReadBoundary struct {
repository.ProjectResourceReader
before func()
}
func (r resourceReadBoundary) ListVisibleDomains(ctx context.Context, actor string) ([]domain.Domain, error) {
r.before()
return r.ProjectResourceReader.ListVisibleDomains(ctx, actor)
}
func TestProjectResourceAPIRechecksAfterSessionValidation(t *testing.T) {
for _, mode := range []string{"revoke", "disable"} {
t.Run(mode, func(t *testing.T) {
f, reader := newProjectResourceAPIFixture(t)
f.server.projectResources = resourceReadBoundary{ProjectResourceReader: f.members, before: func() {
if mode == "revoke" {
if err := f.members.RevokeProjectRole(t.Context(), "primary-super-admin", "project-a", "viewer", domain.ProjectRoleReadOnly); err != nil {
t.Fatal(err)
}
} else if _, err := f.db.ExecContext(t.Context(), `UPDATE users SET status = 'disabled' WHERE id = 'viewer'`); err != nil {
t.Fatal(err)
}
}}
response := serveUserRequest(f.server, f.cookies["viewer"], http.MethodGet, "/api/v1/certificates", "")
if response.Code != http.StatusOK || len(reader.reads) != 0 || !strings.Contains(response.Body.String(), `"items":[]`) {
t.Fatalf("会话通过后撤权仍读取资源:%d,%s,%+v", response.Code, response.Body.String(), reader.reads)
}
})
}
}
func TestProjectResourceAPIMissingDependencyFailsClosed(t *testing.T) {
f, reader := newProjectResourceAPIFixture(t)
f.server.projectResources = nil
for _, path := range []string{
"/api/v1/domains", "/api/v1/servers", "/api/v1/servers/server-a", "/api/v1/certificates",
"/api/v1/certificates/domain-a", "/api/v1/domains/domain-a/certificate/versions", "/api/v1/domains/domain-a/servers",
} {
response := serveUserRequest(f.server, f.cookies["primary-super-admin"], http.MethodGet, path, "")
if response.Code != http.StatusServiceUnavailable || len(reader.reads) != 0 {
t.Fatalf("缺失授权依赖时不应回退全量仓储:%s,%d,%s", path, response.Code, response.Body.String())
}
}
}
func TestProjectResourceAPIAuthenticationAndScopeForgery(t *testing.T) {
f, reader := newProjectResourceAPIFixture(t)
for _, path := range []string{"/api/v1/domains", "/api/v1/servers", "/api/v1/certificates"} {
request := httptest.NewRequest(http.MethodGet, path, nil)
response := httptest.NewRecorder()
f.server.Handler().ServeHTTP(response, request)
if response.Code != http.StatusUnauthorized || response.Header().Get("Cache-Control") != "private, no-store" {
t.Fatalf("未认证读取未拒绝或缺少缓存保护:%s,%d", path, response.Code)
}
request = httptest.NewRequest(http.MethodGet, path, nil)
request.AddCookie(f.cookies["viewer"])
request.Header.Set("Origin", "https://foreign.example.test")
response = httptest.NewRecorder()
f.server.Handler().ServeHTTP(response, request)
if response.Code != http.StatusForbidden {
t.Fatalf("跨来源读取未拒绝:%s,%d", path, response.Code)
}
}
for _, path := range []string{
"/api/v1/servers/server-b?project_id=project-a&actor_id=primary-super-admin",
"/api/v1/certificates/domain-b?project_id=project-a&actor_id=primary-super-admin",
} {
response := serveUserRequest(f.server, f.cookies["viewer"], http.MethodGet, path, "")
if response.Code != http.StatusForbidden || len(reader.reads) != 0 {
t.Fatalf("客户端伪造身份或项目生效:%s,%d", path, response.Code)
}
}
}

View File

@ -118,7 +118,7 @@ func TestHTTPServerUsesProductionTimeoutsAndTLSMinimum(t *testing.T) {
func TestDomainAPIValidatesSANAndHidesInternalErrors(t *testing.T) {
cfg := readyTestConfig(t.TempDir())
repo := &stubDomainRepository{}
server := New(Dependencies{Config: cfg, Repository: repo})
server := New(Dependencies{Config: cfg, Repository: repo, ProjectResources: projectResourceReaderStub{domains: repo}})
invalid := httptest.NewRequest(http.MethodPost, "/api/v1/domains", strings.NewReader(`{"name":"example.com","certificate_type":"san","sans":["bad_domain"]}`))
invalid.Header.Set("Content-Type", "application/json")

View File

@ -51,6 +51,7 @@ type Dependencies struct {
ProjectMembers repository.ProjectMemberRepository
ProjectDirectory repository.ProjectDirectoryRepository
ProjectLifecycle repository.ProjectLifecycleRepository
ProjectResources repository.ProjectResourceReader
SSHKeys *sshkey.Service
DeploymentFactory deployment.ClientFactory
SetupDNS setupDNSVerifier
@ -85,6 +86,7 @@ type Server struct {
projectMembers repository.ProjectMemberRepository
projectDirectory repository.ProjectDirectoryRepository
projectLifecycle repository.ProjectLifecycleRepository
projectResources repository.ProjectResourceReader
sshKeys *sshkey.Service
deploymentFactory deployment.ClientFactory
setupDNS setupDNSVerifier
@ -148,6 +150,7 @@ func New(deps Dependencies) *Server {
projectMembers: deps.ProjectMembers,
projectDirectory: deps.ProjectDirectory,
projectLifecycle: deps.ProjectLifecycle,
projectResources: deps.ProjectResources,
sshKeys: deps.SSHKeys,
deploymentFactory: deps.DeploymentFactory,
issueCertificate: deps.IssueCertificate,
@ -197,8 +200,8 @@ func (s *Server) registerRoutes() {
userAPI.DELETE("/:id", s.requireSuperAdmin, s.deleteUser)
s.registerProjectMemberRoutes()
s.registerProjectLifecycleRoutes()
s.registerProjectResourceRoutes()
s.router.GET("/api/v1/projects", privateProjectResponse, s.requireAuthenticated, s.listVisibleProjects)
adminAPI.GET("/domains", s.listDomains)
adminAPI.POST("/domains", s.dnsOperation, s.createDomain)
adminAPI.PUT("/domains/:id", s.dnsOperation, s.updateDomain)
adminAPI.DELETE("/domains/:id", s.deleteDomain)
@ -215,18 +218,12 @@ func (s *Server) registerRoutes() {
adminAPI.POST("/dns/test", s.dnsOperation, s.testAdminDNS)
adminAPI.POST("/dns/aliyun/check", s.dnsOperation, s.checkAliyunDNSDomain)
adminAPI.POST("/renewal/run", s.longOperation(certificateTaskTimeout), s.runRenewalNow)
adminAPI.GET("/certificates", s.listCertificates)
adminAPI.GET("/certificates/:id", s.getCertificate)
adminAPI.POST("/domains/:id/certificate/import", s.longOperation(certificateTaskTimeout), s.importCertificateFiles)
adminAPI.POST("/domains/:id/certificate/validate", s.longOperation(certificateTaskTimeout), s.validateCertificate)
adminAPI.POST("/domains/:id/deploy", s.longOperation(certificateTaskTimeout), s.deployCertificateCurrent)
adminAPI.GET("/domains/:id/certificate/versions", s.listCertificateVersions)
adminAPI.POST("/domains/:id/certificate/versions/:version/restore", s.longOperation(certificateTaskTimeout), s.restoreCertificateVersion)
adminAPI.GET("/domains/:id/servers", s.listDomainServers)
adminAPI.PUT("/domains/:id/servers", s.replaceDomainServers)
adminAPI.GET("/servers", s.listServers)
adminAPI.POST("/servers", s.createServer)
adminAPI.GET("/servers/:id", s.getServer)
adminAPI.PUT("/servers/:id", s.updateServer)
adminAPI.DELETE("/servers/:id", s.deleteServer)
adminAPI.GET("/ssh-keys", s.listSSHKeys)
@ -529,12 +526,12 @@ func (s *Server) requireDomainRepository(c *gin.Context) bool {
}
func (s *Server) listDomains(c *gin.Context) {
if !s.requireDomainRepository(c) {
if !s.requireProjectResourceReader(c) {
return
}
items, err := s.repository.List(c.Request.Context())
items, err := s.projectResources.ListVisibleDomains(c.Request.Context(), c.GetString(authenticatedActorKey))
if err != nil {
s.writeError(c, http.StatusInternalServerError, "查询域名失败", err)
s.writeProjectResourceError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
@ -676,30 +673,24 @@ func (s *Server) issueDomain(c *gin.Context) {
}
func (s *Server) listServers(c *gin.Context) {
if s.servers == nil {
s.writeError(c, http.StatusNotImplemented, "服务器管理接口尚未配置", errors.New("服务器仓储未配置"))
if !s.requireProjectResourceReader(c) {
return
}
items, err := s.servers.ListServers(c.Request.Context())
items, err := s.projectResources.ListVisibleServers(c.Request.Context(), c.GetString(authenticatedActorKey))
if err != nil {
s.writeError(c, http.StatusInternalServerError, "查询服务器失败", err)
s.writeProjectResourceError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
func (s *Server) getServer(c *gin.Context) {
if s.servers == nil {
s.writeError(c, http.StatusNotImplemented, "服务器管理接口尚未配置", errors.New("服务器仓储未配置"))
if !s.requireProjectResourceReader(c) {
return
}
item, err := s.servers.GetServerByID(c.Request.Context(), c.Param("id"))
item, err := s.projectResources.GetVisibleServer(c.Request.Context(), c.GetString(authenticatedActorKey), c.Param("id"))
if err != nil {
if repository.IsNotFound(err) {
s.writeError(c, http.StatusNotFound, "服务器不存在", err)
return
}
s.writeError(c, http.StatusInternalServerError, "读取服务器失败", err)
s.writeProjectResourceError(c, err)
return
}
c.JSON(http.StatusOK, item)
@ -962,24 +953,12 @@ func (s *Server) deleteServer(c *gin.Context) {
}
func (s *Server) listDomainServers(c *gin.Context) {
if !s.requireDomainRepository(c) {
if !s.requireProjectResourceReader(c) {
return
}
if s.servers == nil {
s.writeError(c, http.StatusNotImplemented, "服务器管理接口尚未配置", errors.New("服务器仓储未配置"))
return
}
if _, err := s.repository.GetByID(c.Request.Context(), c.Param("id")); err != nil {
if repository.IsNotFound(err) {
s.writeError(c, http.StatusNotFound, "域名不存在", err)
return
}
s.writeError(c, http.StatusInternalServerError, "读取域名失败", err)
return
}
items, err := s.servers.ListServersByDomainID(c.Request.Context(), c.Param("id"))
items, err := s.projectResources.ListVisibleDomainServers(c.Request.Context(), c.GetString(authenticatedActorKey), c.Param("id"))
if err != nil {
s.writeError(c, http.StatusInternalServerError, "查询域名部署服务器失败", err)
s.writeProjectResourceError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": items})

View File

@ -43,7 +43,11 @@ func TestHealthAndDomainAPI(t *testing.T) {
t.Fatalf("初始化日志失败:%v", err)
}
defer logger.Close()
server := New(Dependencies{Config: cfg, Logger: logger, Repository: repository.NewSQLDomainRepository(db.DB)})
repo := repository.NewSQLDomainRepository(db.DB)
server := New(Dependencies{
Config: cfg, Logger: logger, Repository: repo,
ProjectResources: projectResourceReaderStub{domains: repo},
})
health := httptest.NewRecorder()
serveAdminHTTP(server, health, httptest.NewRequest(http.MethodGet, "/healthz", nil))
@ -308,6 +312,9 @@ func TestServerAndDomainAssociationAPI(t *testing.T) {
server := New(Dependencies{
Config: cfg, Repository: repository.NewSQLDomainRepository(db.DB),
Servers: repository.NewSQLServerRepository(db.DB), SSHKeys: sshKeys,
ProjectResources: projectResourceReaderStub{
domains: repository.NewSQLDomainRepository(db.DB), servers: repository.NewSQLServerRepository(db.DB),
},
})
domainRequest := httptest.NewRequest(http.MethodPost, "/api/v1/domains", strings.NewReader(`{"name":"example.com","certificate_type":"single"}`))
@ -668,10 +675,10 @@ func TestDomainAPIReportsMissingRepository(t *testing.T) {
response := httptest.NewRecorder()
serveAdminHTTP(server, response, request)
if response.Code != http.StatusNotImplemented {
t.Fatalf("域名仓储未配置时应返回 501:%d %s", response.Code, response.Body.String())
if response.Code != http.StatusServiceUnavailable {
t.Fatalf("项目资源授权仓储未配置时应返回 503:%d %s", response.Code, response.Body.String())
}
if !strings.Contains(response.Body.String(), "域名管理接口尚未配置") {
if !strings.Contains(response.Body.String(), "项目资源服务尚未就绪") {
t.Fatalf("缺少域名仓储错误提示:%s", response.Body.String())
}
}

View File

@ -1,14 +1,44 @@
package httpapi
import (
"context"
"net/http"
"net/http/httptest"
"path/filepath"
"git.pcux.cn/xuans/Scert/backend/internal/config"
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
"git.pcux.cn/xuans/Scert/backend/internal/repository"
)
// 仅供旧的响应格式与错误脱敏单元测试显式注入;权限验证使用真实 SQL 资源夹具。
// 不在产品构造器或通用请求帮助函数中自动补齐,缺失依赖测试必须保持拒绝。
type projectResourceReaderStub struct {
domains repository.DomainRepository
servers repository.ServerRepository
}
func (r projectResourceReaderStub) ListVisibleDomains(ctx context.Context, _ string) ([]domain.Domain, error) {
return r.domains.List(ctx)
}
func (r projectResourceReaderStub) GetVisibleDomain(ctx context.Context, _ string, id string) (domain.Domain, error) {
return r.domains.GetByID(ctx, id)
}
func (r projectResourceReaderStub) ListVisibleServers(ctx context.Context, _ string) ([]deployment.Server, error) {
return r.servers.ListServers(ctx)
}
func (r projectResourceReaderStub) GetVisibleServer(ctx context.Context, _ string, id string) (deployment.Server, error) {
return r.servers.GetServerByID(ctx, id)
}
func (r projectResourceReaderStub) ListVisibleDomainServers(ctx context.Context, _ string, id string) ([]deployment.Server, error) {
return r.servers.ListServersByDomainID(ctx, id)
}
func readyTestConfig(directory string) config.Config {
cfg := config.Default(directory)
// Linux 默认目录属于正式安装,测试必须显式隔离所有可写路径。

View File

@ -66,8 +66,11 @@ func checkProjectDirectory(t *testing.T, cfg config.DatabaseConfig) {
if err != nil || page.Items == nil || len(page.Items) != tc.count || page.HasMore || page.NextCursor != "" {
t.Fatalf("项目可见性错误:%s,%+v,%v", tc.actor, page, err)
}
if page.CanCreateProject != (tc.actor == "root") {
t.Fatalf("空目录也必须返回正确创建能力:%s,%+v", tc.actor, page)
}
for _, item := range page.Items {
if page.CanCreateProject != (tc.actor == "root") || item.CanManageProject != tc.manage || item.CanDeleteProject != (tc.manage && item.ID != domain.DefaultProjectID) {
if 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) {

View File

@ -0,0 +1,163 @@
package repository
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
)
// ProjectResourceReader 将实时权限和资源读取合并,不向外部入口暴露全量基础仓储。
type ProjectResourceReader interface {
ListVisibleDomains(context.Context, string) ([]domain.Domain, error)
GetVisibleDomain(context.Context, string, string) (domain.Domain, error)
ListVisibleServers(context.Context, string) ([]deployment.Server, error)
GetVisibleServer(context.Context, string, string) (deployment.Server, error)
ListVisibleDomainServers(context.Context, string, string) ([]deployment.Server, error)
}
var _ ProjectResourceReader = (*SQLProjectMemberRepository)(nil)
const visibleDomainSelect = `SELECT domains.id, domains.name, domains.certificate_type,
domains.sans_json, domains.auto_renew, domains.acme_environment, domains.dns_provider,
domains.status, domains.expires_at, domains.created_at, domains.updated_at,
domains.project_id, domains.owner_id, domains.created_by, domains.updated_by FROM domains`
// 列名只接受内部固定值,角色能力复用领域模型;所有身份和角色值均使用绑定参数。
// EXISTS 避免多角色账户重复返回资源,不按用户分组或全局普通管理员身份放行。
func resourceReadPredicate(projectColumn, actorID string, permission domain.ProjectPermission) (string, []any) {
if projectColumn != "domains.project_id" && projectColumn != "servers.project_id" {
return "1 = 0", nil
}
if permission != domain.PermissionCertificateRead && permission != domain.PermissionServerRead {
return "1 = 0", nil
}
roles := []domain.ProjectRole{
domain.ProjectRoleAdmin, domain.ProjectRoleCertificateAdmin, domain.ProjectRoleDeploymentAdmin,
domain.ProjectRoleDNSAdmin, domain.ProjectRoleAuditViewer, domain.ProjectRoleReadOnly,
}
args := []any{actorID}
placeholders := make([]string, 0, len(roles))
for _, role := range roles {
if role.Allows(permission) {
placeholders = append(placeholders, "?")
args = append(args, string(role))
}
}
return `EXISTS (SELECT 1 FROM projects resource_project
JOIN users resource_actor ON resource_actor.id = ?
WHERE resource_project.id = ` + projectColumn + ` AND resource_actor.status = 'active'
AND (resource_actor.role = 'super_admin' OR (resource_actor.role IN ('admin', 'user')
AND EXISTS (SELECT 1 FROM project_members resource_member
WHERE resource_member.project_id = resource_project.id AND resource_member.user_id = resource_actor.id
AND resource_member.role IN (` + strings.Join(placeholders, ", ") + `)))))`, args
}
func (r *SQLProjectMemberRepository) resourceReaderReady(actorID string) error {
if err := r.ensureDB(); err != nil {
return err
}
if strings.TrimSpace(actorID) == "" || len(actorID) > 128 {
return ErrProjectAccessDenied
}
return nil
}
func (r *SQLProjectMemberRepository) ListVisibleDomains(ctx context.Context, actorID string) ([]domain.Domain, error) {
if err := r.resourceReaderReady(actorID); err != nil {
return nil, err
}
predicate, args := resourceReadPredicate("domains.project_id", actorID, domain.PermissionCertificateRead)
rows, err := r.db.QueryContext(ctx, Rebind(r.driver, visibleDomainSelect+` WHERE `+predicate+` ORDER BY domains.name, domains.id`), args...)
if err != nil {
return nil, fmt.Errorf("查询可见域名失败:%w", err)
}
defer rows.Close()
items := make([]domain.Domain, 0)
for rows.Next() {
item, err := scanDomain(rows)
if err != nil {
return nil, err
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("读取可见域名失败:%w", err)
}
return items, nil
}
func (r *SQLProjectMemberRepository) GetVisibleDomain(ctx context.Context, actorID, id string) (domain.Domain, error) {
if err := r.resourceReaderReady(actorID); err != nil {
return domain.Domain{}, err
}
predicate, args := resourceReadPredicate("domains.project_id", actorID, domain.PermissionCertificateRead)
args = append(args, id)
item, err := scanDomain(r.db.QueryRowContext(ctx, Rebind(r.driver,
visibleDomainSelect+` WHERE `+predicate+` AND domains.id = ?`), args...))
if errors.Is(err, sql.ErrNoRows) {
return domain.Domain{}, ErrProjectAccessDenied
}
return item, err
}
func (r *SQLProjectMemberRepository) ListVisibleServers(ctx context.Context, actorID string) ([]deployment.Server, error) {
if err := r.resourceReaderReady(actorID); err != nil {
return nil, err
}
predicate, args := resourceReadPredicate("servers.project_id", actorID, domain.PermissionServerRead)
return r.readVisibleServers(ctx, serverSelectQualified+` WHERE `+predicate+` ORDER BY servers.name, servers.id`, args)
}
func (r *SQLProjectMemberRepository) GetVisibleServer(ctx context.Context, actorID, id string) (deployment.Server, error) {
if err := r.resourceReaderReady(actorID); err != nil {
return deployment.Server{}, err
}
predicate, args := resourceReadPredicate("servers.project_id", actorID, domain.PermissionServerRead)
args = append(args, id)
item, err := scanServer(r.db.QueryRowContext(ctx, Rebind(r.driver,
serverSelectQualified+` WHERE `+predicate+` AND servers.id = ?`), args...))
if errors.Is(err, sql.ErrNoRows) {
return deployment.Server{}, ErrProjectAccessDenied
}
return item, err
}
func (r *SQLProjectMemberRepository) ListVisibleDomainServers(ctx context.Context, actorID, domainID string) ([]deployment.Server, error) {
// 对未知和无权限域名统一拒绝;随后的查询仍重判双方权限,撤权后不会返回旧结果。
if _, err := r.GetVisibleDomain(ctx, actorID, domainID); err != nil {
return nil, err
}
domainPredicate, args := resourceReadPredicate("domains.project_id", actorID, domain.PermissionCertificateRead)
serverPredicate, serverArgs := resourceReadPredicate("servers.project_id", actorID, domain.PermissionServerRead)
args = append(args, serverArgs...)
args = append(args, domainID)
return r.readVisibleServers(ctx, serverSelectQualified+`
JOIN domain_servers visible_binding ON visible_binding.server_id = servers.id
JOIN domains ON domains.id = visible_binding.domain_id AND domains.project_id = servers.project_id
WHERE `+domainPredicate+` AND `+serverPredicate+` AND domains.id = ? ORDER BY servers.name, servers.id`, args)
}
func (r *SQLProjectMemberRepository) readVisibleServers(ctx context.Context, query string, args []any) ([]deployment.Server, error) {
rows, err := r.db.QueryContext(ctx, Rebind(r.driver, query), args...)
if err != nil {
return nil, fmt.Errorf("查询可见服务器失败:%w", err)
}
defer rows.Close()
items := make([]deployment.Server, 0)
for rows.Next() {
item, err := scanServer(rows)
if err != nil {
return nil, err
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("读取可见服务器失败:%w", err)
}
return items, nil
}

View File

@ -0,0 +1,165 @@
package repository
import (
"context"
"errors"
"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 TestProjectResourcesSQLite(t *testing.T) {
checkProjectResources(t, config.Default(t.TempDir()).Database)
}
func TestProjectResourcesMySQLReal(t *testing.T) {
server := mysqltest.New(t)
checkProjectResources(t, server.Database(t))
}
func checkProjectResources(t *testing.T, cfg config.DatabaseConfig) {
t.Helper()
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)
}
repo := NewSQLProjectMemberRepository(db.DB, cfg.Driver)
projects := NewSQLProjectRepository(db.DB, cfg.Driver)
domains := NewSQLDomainRepository(db.DB, cfg.Driver)
servers := NewSQLServerRepository(db.DB, cfg.Driver)
for _, user := range []struct{ id, role string }{
{"root", "super_admin"}, {"manager", "user"}, {"cert", "user"}, {"deploy", "admin"},
{"dns", "user"}, {"audit", "user"}, {"reader", "user"}, {"outsider", "admin"},
} {
if _, err := db.DB.ExecContext(t.Context(), Rebind(cfg.Driver, `INSERT INTO users
(id, username, password_hash, role, status) VALUES (?, ?, 'test-only-no-login', ?, 'active')`),
user.id, user.id, user.role); err != nil {
t.Fatal(err)
}
}
for _, id := range []string{"a", "b"} {
if err := projects.CreateProject(t.Context(), domain.Project{ID: "project-" + id, Name: "资源项目"}); err != nil {
t.Fatal(err)
}
item, err := domain.NewDomain(id+".example.test", domain.CertificateSingle, nil)
if err != nil {
t.Fatal(err)
}
item.ID, item.ProjectID = "domain-"+id, "project-"+id
if err := domains.Create(t.Context(), item); err != nil {
t.Fatal(err)
}
server := deployment.Server{
ResourceScope: domain.ResourceScope{ProjectID: item.ProjectID},
ID: "server-" + id, Name: "资源服务器", Address: "192.0.2.10", Port: 22, Username: "deploy",
PrivateKeyRef: "secret://ssh/fixture", HostKeyFingerprint: "SHA256:test",
CertificatePath: "/ssl/cert.pem", PrivateKeyPath: "/ssl/key.pem", WebServer: deployment.WebServerNginx,
}
if err := servers.CreateServer(t.Context(), server); err != nil {
t.Fatal(err)
}
if err := servers.ReplaceDomainServers(t.Context(), item.ID, []string{server.ID}); err != nil {
t.Fatal(err)
}
}
for actor, role := range map[string]domain.ProjectRole{
"manager": domain.ProjectRoleAdmin, "cert": domain.ProjectRoleCertificateAdmin,
"deploy": domain.ProjectRoleDeploymentAdmin, "dns": domain.ProjectRoleDNSAdmin,
"audit": domain.ProjectRoleAuditViewer, "reader": domain.ProjectRoleReadOnly,
} {
if err := repo.GrantProjectRole(t.Context(), "root", "project-a", actor, role); err != nil {
t.Fatal(err)
}
}
if err := repo.GrantProjectRole(t.Context(), "root", "project-a", "manager", domain.ProjectRoleReadOnly); err != nil {
t.Fatal(err)
}
t.Run("lists_filter_and_deduplicate", func(t *testing.T) {
for _, tc := range []struct {
actor string
domains, servers int
}{
{"root", 2, 2}, {"manager", 1, 1}, {"cert", 1, 0}, {"deploy", 1, 1},
{"dns", 0, 0}, {"audit", 0, 0}, {"reader", 1, 1}, {"outsider", 0, 0}, {"missing", 0, 0},
} {
domainItems, err := repo.ListVisibleDomains(t.Context(), tc.actor)
if err != nil || domainItems == nil || len(domainItems) != tc.domains {
t.Fatalf("域名范围错误:%s,%+v,%v", tc.actor, domainItems, err)
}
serverItems, err := repo.ListVisibleServers(t.Context(), tc.actor)
if err != nil || serverItems == nil || len(serverItems) != tc.servers {
t.Fatalf("服务器范围错误:%s,%+v,%v", tc.actor, serverItems, err)
}
if tc.actor != "root" {
for _, item := range domainItems {
if item.ProjectID != "project-a" {
t.Fatalf("跨项目域名泄露:%+v", item)
}
}
for _, item := range serverItems {
if item.ProjectID != "project-a" {
t.Fatalf("跨项目服务器泄露:%+v", item)
}
}
}
}
})
t.Run("details_and_bindings_use_real_scope", func(t *testing.T) {
for _, id := range []string{"b", "missing", "a' OR 1=1 --"} {
if _, err := repo.GetVisibleDomain(t.Context(), "reader", "domain-"+id); !errors.Is(err, ErrProjectAccessDenied) {
t.Fatalf("越权域名应统一拒绝:%s,%v", id, err)
}
if _, err := repo.GetVisibleServer(t.Context(), "reader", "server-"+id); !errors.Is(err, ErrProjectAccessDenied) {
t.Fatalf("越权服务器应统一拒绝:%s,%v", id, err)
}
}
for _, actor := range []string{"root", "manager", "reader", "cert"} {
items, err := repo.ListVisibleDomainServers(t.Context(), actor, "domain-a")
want := 1
if actor == "cert" {
want = 0
}
if err != nil || len(items) != want || (want == 1 && items[0].ID != "server-a") {
t.Fatalf("绑定未核对双方读取权限:%s,%+v,%v", actor, items, err)
}
}
if _, err := repo.ListVisibleDomainServers(t.Context(), "reader", "domain-b"); !errors.Is(err, ErrProjectAccessDenied) {
t.Fatalf("越权绑定查询未拒绝:%v", err)
}
})
t.Run("revocation_and_disabled_account_are_not_cached", func(t *testing.T) {
if err := repo.RevokeProjectRole(t.Context(), "root", "project-a", "reader", domain.ProjectRoleReadOnly); err != nil {
t.Fatal(err)
}
if _, err := repo.GetVisibleDomain(t.Context(), "reader", "domain-a"); !errors.Is(err, ErrProjectAccessDenied) {
t.Fatalf("撤权后仍能读取:%v", err)
}
if _, err := db.DB.ExecContext(t.Context(), `UPDATE users SET status = 'disabled' WHERE id = 'manager'`); err != nil {
t.Fatal(err)
}
for _, actor := range []string{"reader", "manager"} {
items, err := repo.ListVisibleServers(t.Context(), actor)
if err != nil || len(items) != 0 {
t.Fatalf("撤权或停用后仍有资源:%s,%+v,%v", actor, items, err)
}
}
})
t.Run("canceled_and_missing_identity", func(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
cancel()
if _, err := repo.ListVisibleDomains(ctx, "root"); err == nil {
t.Fatal("取消请求不应成功")
}
if _, err := repo.ListVisibleServers(t.Context(), ""); !errors.Is(err, ErrProjectAccessDenied) {
t.Fatalf("空身份未拒绝:%v", err)
}
})
}

View File

@ -1,5 +1,19 @@
# Scert 整改任务执行清单
## 2026 年 9 月 28 日:先开发、后统一测试(20:57 接续)
需求确认:按照用户最新指令先完成开发,再集中运行测试。沿用 P0-06/P0-07 的既定需求,不缩减原始 44 项,不将未测试的源码记为复审通过。本阶段不清库、不部署、不创建辅助验收脚本。
- [x] 核对当前分支和交接差异;生命周期候选已在 `dcca17e`,两处测试修正另以检查点 `fd767b2` 提交推送。
- [x] 生命周期收尾源码已完成,待统一测试:领域、接口及失败审计统一项目标识校验;项目与成员操作共享忙状态,补充控制字符和表单渲染用例。
- [x] 资源读取隔离源码已完成,待统一测试:域名、服务器、绑定列表及证书元数据入口根据真实资源归属判权,列表在 SQL 内过滤。
- [x] 正式应用依赖已接入;缺少授权仓储拒绝读取,不退回旧全量列表;无权限与不存在统一拒绝,内部错误不向客户端透传。
- [~] 补齐模型、接口交接及实际进度文档;SQLite/MySQL、HTTP 和前端回归用例仅编写,不执行。
- [~] 下一子项:将域名绑定服务器的写入改为真实项目授权与事务审计;权限须覆盖双方资源,失败保留旧绑定,不触发 SSH 或部署。
- [!] 统一测试推迟至开发阶段完成之后;构建、浏览器和生产验收均未执行,旧证据不能证明新代码。
历史补记:上一批完整前端 210 项通过且生产构建成功;MySQL 5.7 组合通过,MySQL 8.0 的三条审计故障注入用例因业务账户无建触发器权限失败。夹具改由隔离实例管理员建触发器,未提高业务权限,修正后尚未运行。新增非法项目 ID 回归已复现失败,详见 `artifacts/project-lifecycle-20260928/invalid-id-before.jsonl`。以下 20:47 及更早记录仅为历史快照。
## 2026 年 9 月 28 日:项目生命周期与审计(20:21 开始,20:47 同步)
需求确认:继续 P0-06/P0-07,补齐项目创建、资料修改和空项目删除的正式入口;项目不是用户分组。创建仅允许当前有效超级管理员,修改沿用项目管理员实时权限;删除仍遵守默认项目与域名、服务器、成员引用保护,不自动删除业务资源或成员,不清理现有业务数据。名称允许重名,标识由服务端生成且不可修改。所有成功变更与审计同事务,失败记录不泄露请求正文。

View File

@ -1,10 +1,9 @@
import { computed, onUnmounted, ref, watch } from "vue";
import { handleProjectSession, requestProjectAPI } from "./ProjectRequest.js";
export function useProjectLifecycle(project, canCreateProject, busy, refresh) {
export function useProjectLifecycle(project, canCreateProject, busy, refresh, savingProject = ref(false)) {
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));
@ -87,7 +86,9 @@ export function useProjectLifecycle(project, canCreateProject, busy, refresh) {
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)) {
if (!name || [...name].length > 128 || [...description].length > 4096
|| /[\u0000-\u001f\u007f-\u009f]/.test(name)
|| /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/.test(description)) {
lifecycleError.value = "请填写有效项目名称(最多 128 字),说明最多 4096 字";
return;
}

View File

@ -4,10 +4,50 @@ import { ref } from "vue";
import { mountComposable, deferred } from "../../test-support/mount.js";
import { useProjectLifecycle } from "./ProjectLifecycle.js";
import { useProjectManagement } from "./ProjectManagement.js";
import { useProjectMembers } from "./ProjectMembers.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: "项目乙" };
test("项目保存期间,成员操作函数也拒绝变更和翻页", async (t) => {
const project = ref({ ...projectA, can_manage_members: true });
const busy = ref(false);
const calls = [];
const { state } = await mountComposable(t, () => useProjectMembers(project, busy), async (path, options) => {
calls.push([path, options.method]);
return Response.json({ items: [], has_more: true, next_cursor: "member-next" });
});
const before = calls.length;
busy.value = true;
state.memberForm.value = { userID: "target", role: "read_only" };
await state.grantMember();
state.requestRevoke({ user_id: "target", username: "目标", roles: ["read_only"] }, "read_only");
await state.nextMemberPage();
await state.changeMemberPageSize(30);
assert.equal(calls.length, before);
assert.equal(state.pendingRevoke.value, null);
assert.equal(state.memberPage.value, 0);
assert.equal(state.memberPageSize.value, 10);
});
test("项目资料拒绝控制字符但说明允许换行和制表符", async (t) => {
const calls = [];
const { state } = await setup(t, async (_path, options) => {
calls.push(JSON.parse(options.body));
return Response.json({ id: "created" }, { status: 201 });
});
state.openProjectEditor(true);
for (const [name, description] of [["项目\u0085名称", "说明"], ["项目", "说明\u000b内容"], ["项目", "说明\u009f内容"]]) {
Object.assign(state.projectEditor.value, { name, description });
await state.saveProject();
assert.equal(calls.length, 0);
assert.ok(state.lifecycleError.value);
}
Object.assign(state.projectEditor.value, { name: "项目", description: "第一行\n第二行\t内容" });
await state.saveProject();
assert.deepEqual(calls, [{ name: "项目", description: "第一行\n第二行\t内容" }]);
});
async function setup(t, fetcher, refresh = async () => {}) {
const project = ref({ ...projectA });
const canCreate = ref(true);

View File

@ -14,9 +14,10 @@ export function useProjectManagement() {
const projectHasMore = ref(false);
const projectNextCursor = ref("");
const canCreateProject = ref(false);
const members = useProjectMembers(selectedProject);
const savingProject = ref(false);
const members = useProjectMembers(selectedProject, savingProject);
const lifecycle = useProjectLifecycle(selectedProject, canCreateProject,
computed(() => loadingProjects.value || members.savingMember.value), refreshAfterMutation);
computed(() => loadingProjects.value || members.savingMember.value), refreshAfterMutation, savingProject);
let generation = 0;
let controller;

View File

@ -42,6 +42,36 @@ test("完整成员页面可渲染中文、多个角色、权限拒绝和确认
assert.match(html, /当前没有成员管理权限/);
assert.doesNotMatch(html, /project-grant-form/);
assert.doesNotMatch(html, /unexpected/);
state.canCreateProject.value = true;
state.openProjectEditor(true);
state.projectEditor.value.name = '<img src=x onerror="unexpected">';
state.projectEditor.value.description = "第一行\n第二行";
html = await renderPage();
assert.match(html, /aria-label="项目资料"/);
assert.match(html, /maxlength="128"/);
assert.match(html, /maxlength="4096"/);
assert.match(html, /第一行\n第二行/);
assert.match(html, /&lt;img/);
assert.doesNotMatch(html, /<img src=x/);
state.savingProject.value = true;
html = await renderPage();
assert.match(html, /正在保存……/);
assert.match(html, /<input[^>]*disabled/);
assert.match(html, /<textarea[^>]*disabled/);
state.savingProject.value = false;
state.cancelProjectAction();
state.selectedProject.value = { ...projectA, can_manage_project: true, can_delete_project: true };
state.requestDeleteProject();
html = await renderPage();
assert.match(html, /确认删除项目/);
assert.match(html, /默认项目不能删除/);
assert.doesNotMatch(html, /project-editor/);
state.selectedProject.value = { ...projectA, can_manage_project: false, can_delete_project: false };
state.openProjectEditor();
state.requestDeleteProject();
html = await renderPage();
assert.doesNotMatch(html, /确认删除项目|aria-label="项目资料"/);
});
test("账户页提供本人可分享的账户 ID,不要求用户查看数据库", async () => {

View File

@ -10,7 +10,7 @@ export const projectRoles = [
{ value: "read_only", label: "只读用户" },
];
export function useProjectMembers(project) {
export function useProjectMembers(project, projectBusy = ref(false)) {
const members = ref([]);
const accounts = ref([]);
const loadingMembers = ref(false);
@ -95,7 +95,7 @@ export function useProjectMembers(project) {
}
async function changeMemberPageSize(value) {
if (savingMember.value || !pageSizes.includes(Number(value))) return;
if (projectBusy.value || savingMember.value || !pageSizes.includes(Number(value))) return;
memberPageSize.value = Number(value);
memberPage.value = 0;
cursors.value = [""];
@ -104,7 +104,7 @@ export function useProjectMembers(project) {
}
async function nextMemberPage() {
if (loadingMembers.value || savingMember.value || !memberHasMore.value) return;
if (projectBusy.value || loadingMembers.value || savingMember.value || !memberHasMore.value) return;
cursors.value = [...cursors.value.slice(0, memberPage.value + 1), nextCursor.value];
memberPage.value += 1;
pendingRevoke.value = null;
@ -112,14 +112,14 @@ export function useProjectMembers(project) {
}
async function previousMemberPage() {
if (loadingMembers.value || savingMember.value || memberPage.value === 0) return;
if (projectBusy.value || loadingMembers.value || savingMember.value || memberPage.value === 0) return;
memberPage.value -= 1;
pendingRevoke.value = null;
await loadMembers();
}
async function mutateMember(userID, role, revoke) {
if (!canManageMembers.value || savingMember.value || loadingMembers.value) return;
if (projectBusy.value || !canManageMembers.value || savingMember.value || loadingMembers.value) return;
if (!/^[a-zA-Z0-9_-]{1,128}$/.test(userID) || !projectRoles.some((item) => item.value === role)) {
memberError.value = "请选择有效的账户 ID 和项目角色";
return;
@ -159,7 +159,7 @@ export function useProjectMembers(project) {
}
function requestRevoke(member, role) {
if (!canManageMembers.value || savingMember.value || loadingMembers.value || !member.roles.includes(role)) return;
if (projectBusy.value || !canManageMembers.value || savingMember.value || loadingMembers.value || !member.roles.includes(role)) return;
pendingRevoke.value = { userID: member.user_id, username: member.username, role };
}