支持同项目内域名与服务器双向复用,局部修改和清空不影响其他域名;在绑定保存、服务器编辑和部署前拒绝已知共享路径冲突。 新增仓储、接口、工作流与页面回归源码,同步任务状态和第三版接口交接。按要求先开发后统一测试,本批未运行测试、构建或部署,保留在途任务与主机别名等待办。
174 lines
6.8 KiB
Go
174 lines
6.8 KiB
Go
package repository
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"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, nil)
|
||
}
|
||
|
||
func TestProjectResourcesMySQLReal(t *testing.T) {
|
||
server := mysqltest.New(t)
|
||
checkProjectResources(t, server.Database(t), server.Admin())
|
||
}
|
||
|
||
func checkProjectResources(t *testing.T, cfg config.DatabaseConfig, admin *sql.DB) {
|
||
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)
|
||
}
|
||
})
|
||
checkProjectBindings(t, db.DB, admin, repo, cfg.Driver)
|
||
t.Run("many_to_many_bindings", func(t *testing.T) {
|
||
checkManyToManyBindings(t, db.DB, admin, repo, cfg.Driver)
|
||
})
|
||
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"} {
|
||
if _, err := repo.ReplaceProjectDomainServers(t.Context(), actor, "domain-a", []string{}); !errors.Is(err, ErrProjectAccessDenied) {
|
||
t.Fatalf("撤权或停用后仍可修改绑定:%s,%v", actor, err)
|
||
}
|
||
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)
|
||
}
|
||
})
|
||
}
|