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 <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-14 07:54:43 +00:00
co-authored by Cursor
parent b4699e9cbe
commit 66d2a1837f
4 changed files with 263 additions and 4 deletions
@@ -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)
}
+107
View File
@@ -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: "当前用户不是该文档职责人",
}
}
@@ -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)
}
}
+14 -4
View File
@@ -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)
}
}