Files
Agent-System/internal/projectworkflow/service_test.go
T
weichenandCursor 5cd22c7327 fix(authz): harden permissions, chat gate, and project create idempotency
Add project:manage_members, CSP frame-src, task-run member view, CreateUser
tx error handling, ERR_NO_PROJECT chat gate, and creator-scoped project
name/client_key uniqueness to stop duplicate creates and auth gaps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 07:04:57 +00:00

351 lines
12 KiB
Go

package projectworkflow
import (
"context"
"errors"
"testing"
"unicode/utf8"
"chat-agent-system/internal/auth"
)
func TestGenerateProjectCode_ValidUTF8ForChineseName(t *testing.T) {
longChinese := "数字化高频超声手术吸引系统治理链测试项目名称"
code := generateProjectCode(longChinese)
if !utf8.ValidString(code) {
t.Fatalf("project code must be valid UTF-8, got %q bytes=% x", code, []byte(code))
}
if code == "" {
t.Fatal("expected non-empty code")
}
}
func TestProjectWorkflowService_CreateAndCreatorACL(t *testing.T) {
ctx := context.Background()
store := NewStore(nil)
catalog := NewDefaultCatalog()
svc := NewService(store, catalog)
creator := &auth.UserAccount{ID: 101, Username: "zhangsan", Name: "张工"}
otherUser := &auth.UserAccount{ID: 102, Username: "lisi", Name: "李主管"}
// 1. 未认证创建应被拦截
_, err := svc.CreateProject(ctx, nil, CreateProjectInput{Name: "测试项目A"})
if err != ErrUnauthenticated {
t.Fatalf("expected ErrUnauthenticated, got: %v", err)
}
// 2. 正常创建
proj, err := svc.CreateProject(ctx, creator, CreateProjectInput{
Name: "神经脉冲射频仪 (NeuroPulse)",
DeviceType: "高频神经射频治疗设备",
IntendedUse: "用于中枢神经阻滞与射频毁损治疗",
TargetMarket: "NMPA Class III",
})
if err != nil {
t.Fatalf("CreateProject failed: %v", err)
}
if proj.CreatorUserID != creator.ID {
t.Errorf("expected CreatorUserID %d, got %d", creator.ID, proj.CreatorUserID)
}
if proj.LifecycleStatus != "planning" {
t.Errorf("expected lifecycle 'planning', got '%s'", proj.LifecycleStatus)
}
if proj.ActiveWorkflowKey != "" {
t.Errorf("expected empty active workflow in planning mode, got '%s'", proj.ActiveWorkflowKey)
}
if proj.WorkflowSelectionVersion != 1 {
t.Errorf("expected initial version 1, got %d", proj.WorkflowSelectionVersion)
}
keyed, err := svc.CreateProject(ctx, creator, CreateProjectInput{
Name: "带初始工作流键的规划项目",
InitialWorkflowKey: "main",
})
if err != nil {
t.Fatalf("CreateProject with initial key: %v", err)
}
if keyed.LifecycleStatus != "planning" || keyed.ActiveWorkflowKey != "" {
t.Fatalf("initial_workflow_key must not activate, got lifecycle=%s key=%s", keyed.LifecycleStatus, keyed.ActiveWorkflowKey)
}
keyedWFs, err := svc.ListProjectWorkflows(ctx, keyed.ID)
if err != nil {
t.Fatalf("list keyed workflows: %v", err)
}
if len(keyedWFs) != 6 {
t.Fatalf("catalog must still mount after keyed create, got %d", len(keyedWFs))
}
// 3. 验证自动挂载的工作流清单
wfs, err := svc.ListProjectWorkflows(ctx, proj.ID)
if err != nil {
t.Fatalf("ListProjectWorkflows failed: %v", err)
}
if len(wfs) != 6 {
t.Fatalf("expected 6 default workflows, got %d", len(wfs))
}
// 4. 非创建人尝试切换工作流 -> 必须被拒绝
_, err = svc.ActivateWorkflow(ctx, proj.ID, otherUser.ID, ActivateWorkflowInput{
WorkflowKey: "product_manager",
ExpectedVersion: 1,
Reason: "尝试越权切换",
})
if err != ErrForbiddenNotCreator {
t.Fatalf("expected ErrForbiddenNotCreator, got: %v", err)
}
// 5. 创建人正常切换工作流 -> 成功升级版本
res, err := svc.ActivateWorkflow(ctx, proj.ID, creator.ID, ActivateWorkflowInput{
WorkflowKey: "product_manager",
ExpectedVersion: 1,
Reason: "进入产品需求阶段",
})
if err != nil {
t.Fatalf("ActivateWorkflow failed: %v", err)
}
if res.ActiveWorkflowKey != "product_manager" {
t.Errorf("expected active workflow 'product_manager', got '%s'", res.ActiveWorkflowKey)
}
if res.Version != 2 {
t.Errorf("expected version 2 after switch, got %d", res.Version)
}
// 6. 并发冲突:使用旧版本 1 再次切换 -> 必须返回 ErrConcurrentConflict
_, err = svc.ActivateWorkflow(ctx, proj.ID, creator.ID, ActivateWorkflowInput{
WorkflowKey: "test_manager",
ExpectedVersion: 1, // 旧版本
Reason: "并发落后切换",
})
if err != ErrConcurrentConflict {
t.Fatalf("expected ErrConcurrentConflict, got: %v", err)
}
// 7. 幂等测试:重复激活当前工作流 'product_manager'
resIdempotent, err := svc.ActivateWorkflow(ctx, proj.ID, creator.ID, ActivateWorkflowInput{
WorkflowKey: "product_manager",
ExpectedVersion: 2,
Reason: "重复激活",
})
if err != nil {
t.Fatalf("idempotent switch failed: %v", err)
}
if resIdempotent.ActiveWorkflowKey != "product_manager" {
t.Errorf("expected active 'product_manager', got '%s'", resIdempotent.ActiveWorkflowKey)
}
// 8. 切换到不存在的工作流 -> 返回 ErrWorkflowNotFound
_, err = svc.ActivateWorkflow(ctx, proj.ID, creator.ID, ActivateWorkflowInput{
WorkflowKey: "non_existent_wf",
})
if err == nil || !errors.Is(err, ErrWorkflowNotFound) {
t.Fatalf("expected ErrWorkflowNotFound, got: %v", err)
}
}
func TestProjectWorkflowService_ListAllProjects(t *testing.T) {
ctx := context.Background()
svc := NewService(NewStore(nil), NewDefaultCatalog())
creator := &auth.UserAccount{ID: 102, Username: "lisi", Name: "李主管"}
created, err := svc.CreateProject(ctx, creator, CreateProjectInput{Name: "管理员跨项目查看测试"})
if err != nil {
t.Fatalf("CreateProject failed: %v", err)
}
visible, err := svc.ListProjects(ctx, 999)
if err != nil {
t.Fatalf("ListProjects failed: %v", err)
}
if len(visible) != 0 {
t.Fatalf("unrelated user should not see projects, got %d", len(visible))
}
all, err := svc.ListAllProjects(ctx)
if err != nil {
t.Fatalf("ListAllProjects failed: %v", err)
}
found := false
for _, project := range all {
if project.ID == created.ID {
found = true
break
}
}
if !found {
t.Fatalf("ListAllProjects should include project %d", created.ID)
}
}
func TestProjectWorkflowService_LineageAndStale(t *testing.T) {
ctx := context.Background()
store := NewStore(nil)
catalog := NewDefaultCatalog()
svc := NewService(store, catalog)
creator := &auth.UserAccount{ID: 101, Username: "zhangsan", Name: "张工"}
proj, _ := svc.CreateProject(ctx, creator, CreateProjectInput{Name: "超声手术刀"})
// 记录测试经理方案依赖产品经理需求规格书 v1
err := svc.RecordLineage(ctx, &DocumentLineage{
ProjectID: proj.ID,
DownstreamDocID: "doc_test_plan_001",
DownstreamVersion: 1,
UpstreamDocID: "doc_system_reqs_001",
UpstreamVersion: 1,
UpstreamSHA256: "hash_v1_aaa",
})
if err != nil {
t.Fatalf("RecordLineage failed: %v", err)
}
lineages, err := svc.GetLineageForDownstream(ctx, "doc_test_plan_001", 1)
if err != nil || len(lineages) == 0 {
t.Fatalf("expected lineage record, got err: %v, len: %d", err, len(lineages))
}
if lineages[0].IsStale {
t.Errorf("initial lineage should not be stale")
}
// 上游生成相同 SHA256 -> 不触发 stale
staleUpdates, err := svc.CheckAndPropagateStale(ctx, "doc_system_reqs_001", "hash_v1_aaa", "")
if err != nil {
t.Fatalf("CheckAndPropagateStale failed: %v", err)
}
if len(staleUpdates) > 0 {
t.Errorf("expected 0 stale updates for identical hash, got %d", len(staleUpdates))
}
// 上游生成不同 SHA256 (内容更新) -> 触发下游 stale 传播
staleUpdates, err = svc.CheckAndPropagateStale(ctx, "doc_system_reqs_001", "hash_v2_bbb", "系统需求新增 RF-009 射频功率限制")
if err != nil {
t.Fatalf("CheckAndPropagateStale failed: %v", err)
}
if len(staleUpdates) != 1 {
t.Fatalf("expected 1 stale update, got %d", len(staleUpdates))
}
if !staleUpdates[0].IsStale {
t.Errorf("lineage should be marked as stale")
}
if staleUpdates[0].DownstreamDocID != "doc_test_plan_001" {
t.Errorf("expected downstream 'doc_test_plan_001', got '%s'", staleUpdates[0].DownstreamDocID)
}
}
func TestCreateSoftwareProject_NoMedicalDefaults(t *testing.T) {
ctx := context.Background()
svc := NewService(NewStore(nil), NewDefaultCatalog())
creator := &auth.UserAccount{ID: 201, Username: "dev", Name: "开发者"}
proj, err := svc.CreateProject(ctx, creator, CreateProjectInput{
Name: "Agent-System 自举",
ProductDomain: "non_medical_software",
ProjectTypeKey: "existing_self_bootstrap",
ComplianceProfileKey: "internal_control+security_baseline",
GovernanceProfile: "standard",
TeamMode: "solo",
ExecutionMode: "docs_only",
ProblemStatement: "建立可信路线",
TargetUsers: "内部团队",
TargetOutcome: "可执行发布路线",
InitialWorkflowKey: "software_bootstrap",
})
if err != nil {
t.Fatalf("CreateProject: %v", err)
}
if proj.ProductDomain != "non_medical_software" || proj.ProjectTypeKey != "existing_self_bootstrap" {
t.Fatalf("domain/type not persisted: %+v", proj)
}
if proj.CurrentStage != "S0_boundary" {
t.Fatalf("expected S0_boundary stage, got %s", proj.CurrentStage)
}
if proj.DeviceType != "" || proj.IntendedUse != "" {
t.Fatalf("software create must not invent medical device fields: device=%q use=%q", proj.DeviceType, proj.IntendedUse)
}
if proj.ActiveWorkflowKey != "" {
t.Fatal("must remain planning without activation")
}
wfs, err := svc.ListProjectWorkflows(ctx, proj.ID)
if err != nil {
t.Fatal(err)
}
got := map[string]bool{}
for _, wf := range wfs {
got[wf.WorkflowKey] = true
if wf.Status == "active" {
t.Fatalf("software create must not auto-activate %s", wf.WorkflowKey)
}
}
if !got["software_bootstrap"] || !got["software_delivery"] {
t.Fatalf("expected software keys, got %v", got)
}
if got["main"] || got["product_manager"] || got["test_manager"] || got["rdev"] {
t.Fatalf("software project must not mount medical keys: %v", got)
}
med, err := svc.CreateProject(ctx, creator, CreateProjectInput{
Name: "医疗回归", ProductDomain: "medical_device", DeviceType: "超声",
})
if err != nil {
t.Fatal(err)
}
mwfs, err := svc.ListProjectWorkflows(ctx, med.ID)
if err != nil {
t.Fatal(err)
}
mgot := map[string]bool{}
for _, wf := range mwfs {
mgot[wf.WorkflowKey] = true
}
if !mgot["main"] || !mgot["product_manager"] || !mgot["test_manager"] || !mgot["rdev"] {
t.Fatalf("medical project must mount four medical keys: %v", mgot)
}
_, err = svc.CreateProject(ctx, creator, CreateProjectInput{
Name: "bad",
ProductDomain: "non_medical_software",
ExecutionMode: "controlled_patch",
})
if err == nil || !errors.Is(err, ErrInvalidInput) {
t.Fatalf("expected controlled_patch rejection, got %v", err)
}
}
func TestCreateProject_ClientKeyIdempotentAndDuplicateName(t *testing.T) {
svc := NewService(newMemStore(), NewDefaultCatalog())
ctx := context.Background()
creator := &auth.UserAccount{ID: 42, Username: "creator"}
first, err := svc.CreateProject(ctx, creator, CreateProjectInput{
Name: "幂等项目",
ClientKey: "ck-same-1",
})
if err != nil {
t.Fatalf("first create: %v", err)
}
second, err := svc.CreateProject(ctx, creator, CreateProjectInput{
Name: "幂等项目-改名也应返回同一条",
ClientKey: "ck-same-1",
})
if err != nil {
t.Fatalf("idempotent create: %v", err)
}
if first.ID != second.ID {
t.Fatalf("expected same project id, got %d vs %d", first.ID, second.ID)
}
_, err = svc.CreateProject(ctx, creator, CreateProjectInput{Name: "幂等项目"})
if err == nil || !errors.Is(err, ErrDuplicateProjectName) {
t.Fatalf("expected ErrDuplicateProjectName, got %v", err)
}
member := &auth.UserAccount{ID: 99, Username: "other"}
other, err := svc.CreateProject(ctx, member, CreateProjectInput{Name: "幂等项目"})
if err != nil {
t.Fatalf("different creator same name should succeed: %v", err)
}
if other.ID == first.ID {
t.Fatalf("different creators must get distinct projects")
}
}