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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user