From 66d2a1837f51d2a2efe546b68be6c565ff367e78 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 14 Sep 2026 07:54:43 +0000 Subject: [PATCH] test(projectinstance): pin action-level duty checks Lock the full/none/pre-activation matrix so creator no longer implies document-task privilege and unactivated runs stay WORKFLOW_NOT_ACTIVATED. Co-authored-by: Cursor --- .../projectinstance/document_access_test.go | 50 ++++++++ internal/projectinstance/duty_action.go | 107 ++++++++++++++++++ internal/projectinstance/duty_action_test.go | 92 +++++++++++++++ internal/projectinstance/stage_access_test.go | 18 ++- 4 files changed, 263 insertions(+), 4 deletions(-) create mode 100644 internal/projectinstance/duty_action.go create mode 100644 internal/projectinstance/duty_action_test.go diff --git a/internal/projectinstance/document_access_test.go b/internal/projectinstance/document_access_test.go index 728f779a..872cd564 100644 --- a/internal/projectinstance/document_access_test.go +++ b/internal/projectinstance/document_access_test.go @@ -102,4 +102,54 @@ func TestCheckDocTaskAccess_UnmanagedDutyModes(t *testing.T) { if approve.Allowed || approve.Code != AccessCodeDutyNotManaged { t.Fatalf("approve on none must be DOC_DUTY_NOT_MANAGED, got %+v", approve) } + submit := svc.CheckDocTaskAccess(ctx, 402, 1, "research", DocAccessActionSubmit, true) + if submit.Allowed || submit.Code != AccessCodeDutyNotManaged { + t.Fatalf("submit on none must be DOC_DUTY_NOT_MANAGED, got %+v", submit) + } +} + +func TestCheckDocTaskAccess_FullActionMatrix(t *testing.T) { + ctx := context.Background() + store := NewMemoryStore() + _ = SeedProjectMeta(store, 403, LifecyclePlanning, "", 1, 0) + svc := NewService(store, TestResolver(), nil, nil) + plan := ValidTestPlan() + plan.Stages[0].DocTasks[0].Duties = DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3} + if _, err := svc.SaveWorkflowPlan(ctx, 403, 9, true, SavePlanInput{ + Plan: plan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }); err != nil { + t.Fatalf("save: %v", err) + } + preview, err := svc.ActivationPreview(ctx, 403, 9, true) + if err != nil { + t.Fatalf("preview: %v", err) + } + if _, err := svc.Activate(ctx, 403, 9, true, ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }); err != nil { + t.Fatalf("activate: %v", err) + } + + assert := func(userID int64, isCreator bool, action string, wantAllow bool, wantCode string) { + t.Helper() + got := svc.CheckDocTaskAccess(ctx, 403, userID, "intake", action, isCreator) + if got.Allowed != wantAllow || (wantCode != "" && got.Code != wantCode) { + t.Fatalf("user=%d creator=%v action=%s got %+v want allow=%v code=%s", userID, isCreator, action, got, wantAllow, wantCode) + } + } + assert(1, false, DocAccessActionRun, true, "") + assert(1, false, DocAccessActionReview, false, AccessCodeForbiddenDuty) + assert(1, false, DocAccessActionApprove, false, AccessCodeForbiddenDuty) + assert(2, false, DocAccessActionRun, false, AccessCodeForbiddenDuty) + assert(2, false, DocAccessActionSubmit, false, AccessCodeForbiddenDuty) + assert(2, false, DocAccessActionReview, true, "") + assert(3, false, DocAccessActionRun, false, AccessCodeForbiddenDuty) + assert(3, false, DocAccessActionReview, false, AccessCodeForbiddenDuty) + assert(3, false, DocAccessActionApprove, true, "") + assert(9, true, DocAccessActionView, true, "") + assert(9, true, DocAccessActionRun, false, AccessCodeForbiddenDuty) + assert(9, true, DocAccessActionSubmit, false, AccessCodeForbiddenDuty) + assert(9, true, DocAccessActionReview, false, AccessCodeForbiddenDuty) + assert(9, true, DocAccessActionApprove, false, AccessCodeForbiddenDuty) } diff --git a/internal/projectinstance/duty_action.go b/internal/projectinstance/duty_action.go new file mode 100644 index 00000000..d6311d33 --- /dev/null +++ b/internal/projectinstance/duty_action.go @@ -0,0 +1,107 @@ +package projectinstance + +const ( + DutyRoleAuthor = "author" + DutyRoleReviewer = "reviewer" + DutyRoleApprover = "approver" + + AccessCodeNotActivated = "WORKFLOW_NOT_ACTIVATED" + + GovernanceBlockedHint = "当前项目尚未激活工作流,文档任务不会启动。请先在设置中配置并激活至少一份纳入人工审批的文档。" +) + +// requiredDutyRole maps a document action to the single duty that may perform it. +func requiredDutyRole(action string) string { + switch action { + case DocAccessActionRun, DocAccessActionEdit, DocAccessActionSubmit: + return DutyRoleAuthor + case DocAccessActionReview: + return DutyRoleReviewer + case DocAccessActionApprove: + return DutyRoleApprover + default: + return "" + } +} + +func userHoldsDuty(d DocDuty, userID int64, role string) bool { + if userID <= 0 || role == "" { + return false + } + switch role { + case DutyRoleAuthor: + return d.AuthorUserID == userID + case DutyRoleReviewer: + return d.ReviewerUserID == userID + case DutyRoleApprover: + return d.ApproverUserID == userID + default: + return false + } +} + +// classifyAccess applies the locked duty-action matrix. govLive=false is pre-activation. +func classifyAccess(dt DocTaskPlan, action string, userID int64, isCreator bool, govLive bool) DocAccessDecision { + allow := DocAccessDecision{Allowed: true} + if action == DocAccessActionView { + return allow + } + if !govLive { + if action == DocAccessActionRun { + return DocAccessDecision{ + Allowed: true, + Code: AccessCodeNotActivated, + Message: GovernanceBlockedHint, + } + } + return DocAccessDecision{ + Allowed: false, + Code: AccessCodeNotActivated, + Message: GovernanceBlockedHint, + } + } + if !dt.Enabled { + return DocAccessDecision{Allowed: false, Code: AccessCodeDocTaskDisabled, Message: "该文档已被创建者禁用"} + } + switch classifyDocDuty(dt.Duties) { + case DutyModeNone: + switch action { + case DocAccessActionRun, DocAccessActionEdit: + if isCreator { + return allow + } + return DocAccessDecision{ + Allowed: false, + Code: AccessCodeForbiddenDuty, + Message: "未纳入人工审批的文档仅创建者可编辑或执行", + } + case DocAccessActionReview, DocAccessActionApprove, DocAccessActionSubmit: + return DocAccessDecision{ + Allowed: false, + Code: AccessCodeDutyNotManaged, + Message: "该文档未纳入人工审批,不能审核、批准或提交审批", + } + } + case DutyModePartial: + return DocAccessDecision{ + Allowed: false, + Code: AccessCodeForbiddenDuty, + Message: "文档职责未完整指派,不能执行该动作", + } + case DutyModeFull: + role := requiredDutyRole(action) + if userHoldsDuty(dt.Duties, userID, role) { + return allow + } + return DocAccessDecision{ + Allowed: false, + Code: AccessCodeForbiddenDuty, + Message: "当前用户不是该文档的对应职责人", + } + } + return DocAccessDecision{ + Allowed: false, + Code: AccessCodeForbiddenDuty, + Message: "当前用户不是该文档职责人", + } +} diff --git a/internal/projectinstance/duty_action_test.go b/internal/projectinstance/duty_action_test.go new file mode 100644 index 00000000..52642c48 --- /dev/null +++ b/internal/projectinstance/duty_action_test.go @@ -0,0 +1,92 @@ +package projectinstance + +import "testing" + +func TestRequiredDutyRole(t *testing.T) { + if requiredDutyRole(DocAccessActionRun) != DutyRoleAuthor { + t.Fatal("run -> author") + } + if requiredDutyRole(DocAccessActionEdit) != DutyRoleAuthor { + t.Fatal("edit -> author") + } + if requiredDutyRole(DocAccessActionSubmit) != DutyRoleAuthor { + t.Fatal("submit -> author") + } + if requiredDutyRole(DocAccessActionReview) != DutyRoleReviewer { + t.Fatal("review -> reviewer") + } + if requiredDutyRole(DocAccessActionApprove) != DutyRoleApprover { + t.Fatal("approve -> approver") + } + if requiredDutyRole(DocAccessActionView) != "" { + t.Fatal("view has no duty role") + } +} + +func TestClassifyAccess_FullDutyMatrix(t *testing.T) { + dt := DocTaskPlan{Enabled: true, Duties: DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3}} + creatorID := int64(9) + + cases := []struct { + name string + userID int64 + isCreator bool + action string + wantAllow bool + wantCode string + }{ + {"author run", 1, false, DocAccessActionRun, true, ""}, + {"author edit", 1, false, DocAccessActionEdit, true, ""}, + {"author submit", 1, false, DocAccessActionSubmit, true, ""}, + {"author review denied", 1, false, DocAccessActionReview, false, AccessCodeForbiddenDuty}, + {"author approve denied", 1, false, DocAccessActionApprove, false, AccessCodeForbiddenDuty}, + {"reviewer run denied", 2, false, DocAccessActionRun, false, AccessCodeForbiddenDuty}, + {"reviewer edit denied", 2, false, DocAccessActionEdit, false, AccessCodeForbiddenDuty}, + {"reviewer submit denied", 2, false, DocAccessActionSubmit, false, AccessCodeForbiddenDuty}, + {"reviewer review", 2, false, DocAccessActionReview, true, ""}, + {"approver run denied", 3, false, DocAccessActionRun, false, AccessCodeForbiddenDuty}, + {"approver edit denied", 3, false, DocAccessActionEdit, false, AccessCodeForbiddenDuty}, + {"approver review denied", 3, false, DocAccessActionReview, false, AccessCodeForbiddenDuty}, + {"approver approve", 3, false, DocAccessActionApprove, true, ""}, + {"creator view", creatorID, true, DocAccessActionView, true, ""}, + {"creator run denied", creatorID, true, DocAccessActionRun, false, AccessCodeForbiddenDuty}, + {"creator submit denied", creatorID, true, DocAccessActionSubmit, false, AccessCodeForbiddenDuty}, + {"creator review denied", creatorID, true, DocAccessActionReview, false, AccessCodeForbiddenDuty}, + {"creator approve denied", creatorID, true, DocAccessActionApprove, false, AccessCodeForbiddenDuty}, + {"reviewer cannot re-run after reject", 2, false, DocAccessActionRun, false, AccessCodeForbiddenDuty}, + } + for _, tc := range cases { + got := classifyAccess(dt, tc.action, tc.userID, tc.isCreator, true) + if got.Allowed != tc.wantAllow || (tc.wantCode != "" && got.Code != tc.wantCode) { + t.Fatalf("%s: got %+v want allow=%v code=%s", tc.name, got, tc.wantAllow, tc.wantCode) + } + } +} + +func TestClassifyAccess_NoneAndPreActivation(t *testing.T) { + none := DocTaskPlan{Enabled: true, Duties: DocDuty{}} + creatorRun := classifyAccess(none, DocAccessActionRun, 1, true, true) + if !creatorRun.Allowed { + t.Fatalf("creator run on none: %+v", creatorRun) + } + creatorEdit := classifyAccess(none, DocAccessActionEdit, 1, true, true) + if !creatorEdit.Allowed { + t.Fatalf("creator edit on none: %+v", creatorEdit) + } + memberRun := classifyAccess(none, DocAccessActionRun, 2, false, true) + if memberRun.Allowed || memberRun.Code != AccessCodeForbiddenDuty { + t.Fatalf("member run on none: %+v", memberRun) + } + submit := classifyAccess(none, DocAccessActionSubmit, 1, true, true) + if submit.Allowed || submit.Code != AccessCodeDutyNotManaged { + t.Fatalf("submit on none: %+v", submit) + } + preRun := classifyAccess(none, DocAccessActionRun, 1, true, false) + if !preRun.Allowed || preRun.Code != AccessCodeNotActivated { + t.Fatalf("pre-activation run: %+v", preRun) + } + preSubmit := classifyAccess(none, DocAccessActionSubmit, 1, true, false) + if preSubmit.Allowed || preSubmit.Code != AccessCodeNotActivated { + t.Fatalf("pre-activation submit: %+v", preSubmit) + } +} diff --git a/internal/projectinstance/stage_access_test.go b/internal/projectinstance/stage_access_test.go index 14142d0a..b58a35dc 100644 --- a/internal/projectinstance/stage_access_test.go +++ b/internal/projectinstance/stage_access_test.go @@ -24,6 +24,9 @@ func TestResolveStageAccess_ActiveStageDutyOnly(t *testing.T) { if access.TaskKeyAllowed("intake") { t.Fatal("user without duty should not access active stage docs") } + if !access.TaskKeyVisible("intake") { + t.Fatal("view must stay open for enabled docs even without duty") + } authorAccess, _ := svc.ResolveStageAccess(ctx, 42, 1, false) if !authorAccess.TaskKeyAllowed("intake") { @@ -70,11 +73,14 @@ func TestResolveStageAccess_UnmanagedDocsStayVisible(t *testing.T) { if err != nil { t.Fatal(err) } - if !member.StageKeyAllowed("V0.1_triage") || !member.TaskKeyAllowed("research") { + if !member.TaskKeyVisible("research") { t.Fatalf("none docs must stay visible to members, got %+v", member) } + if member.TaskKeyAllowed("research") { + t.Fatal("members must not launch unmanaged docs") + } if member.TaskKeyAllowed("intake") { - t.Fatal("members without intake duty must not see the governed task") + t.Fatal("members without intake duty must not act on the governed task") } } @@ -198,8 +204,12 @@ func TestCheckDocTaskAccess_NoGoverningAllows(t *testing.T) { _ = SeedProjectMeta(store, 80, LifecyclePlanning, "", 1, 0) svc := NewService(store, testResolver(), nil, nil) d := svc.CheckDocTaskAccess(ctx, 80, 1, "intake", DocAccessActionRun, true) - if !d.Allowed { - t.Fatalf("expected allow without governing instance, got %+v", d) + if !d.Allowed || d.Code != AccessCodeNotActivated { + t.Fatalf("pre-activation run must allow with WORKFLOW_NOT_ACTIVATED, got %+v", d) + } + submit := svc.CheckDocTaskAccess(ctx, 80, 1, "intake", DocAccessActionSubmit, true) + if submit.Allowed || submit.Code != AccessCodeNotActivated { + t.Fatalf("pre-activation submit must deny, got %+v", submit) } }