test: cover author_only access matrix and duty_policy audits
Prove A can run the full action chain and outsiders stay denied; assert audit Details carry duty_policy on all five transitions. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
package docgov
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTransitionAuditDetailsIncludeDutyPolicy(t *testing.T) {
|
||||
sm := NewStateMachine()
|
||||
doc := &DocumentRecord{
|
||||
DocID: "doc_audit_policy",
|
||||
CurrentState: StateDrafting,
|
||||
AuthorID: 7,
|
||||
ReviewerID: 7,
|
||||
ApproverID: 7,
|
||||
DutyPolicy: "author_only",
|
||||
DutyLocked: true,
|
||||
Config: DefaultDocumentConfig(),
|
||||
}
|
||||
|
||||
audit, err := sm.TransitionSubmitForReview(doc, 7, RoleAuthor, "A")
|
||||
if err != nil {
|
||||
t.Fatalf("submit: %v", err)
|
||||
}
|
||||
if audit.Details["duty_policy"] != "author_only" {
|
||||
t.Fatalf("submit details: %+v", audit.Details)
|
||||
}
|
||||
|
||||
audit, err = sm.TransitionReviewApprove(doc, 7, RoleReviewer, "A", "ok")
|
||||
if err != nil {
|
||||
t.Fatalf("review: %v", err)
|
||||
}
|
||||
if audit.Details["duty_policy"] != "author_only" {
|
||||
t.Fatalf("review details: %+v", audit.Details)
|
||||
}
|
||||
|
||||
audit, err = sm.TransitionApproverApprove(doc, 7, RoleApprover, "A", "ok")
|
||||
if err != nil {
|
||||
t.Fatalf("approve: %v", err)
|
||||
}
|
||||
if audit.Details["duty_policy"] != "author_only" {
|
||||
t.Fatalf("approve details: %+v", audit.Details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransitionRejectAuditDetailsIncludeDutyPolicy(t *testing.T) {
|
||||
sm := NewStateMachine()
|
||||
doc := &DocumentRecord{
|
||||
DocID: "doc_audit_reject",
|
||||
CurrentState: StateReviewing,
|
||||
IsLocked: true,
|
||||
AuthorID: 7,
|
||||
ReviewerID: 7,
|
||||
ApproverID: 7,
|
||||
DutyPolicy: "author_only",
|
||||
Config: DefaultDocumentConfig(),
|
||||
}
|
||||
audit, err := sm.TransitionReviewReject(doc, 7, RoleReviewer, "A", "need fix")
|
||||
if err != nil {
|
||||
t.Fatalf("review reject: %v", err)
|
||||
}
|
||||
if audit.Details["duty_policy"] != "author_only" {
|
||||
t.Fatalf("review reject details: %+v", audit.Details)
|
||||
}
|
||||
|
||||
doc.CurrentState = StateApproving
|
||||
doc.IsLocked = true
|
||||
audit, err = sm.TransitionApproverReject(doc, 7, RoleApprover, "A", "need rework", StateDrafting)
|
||||
if err != nil {
|
||||
t.Fatalf("approver reject: %v", err)
|
||||
}
|
||||
if audit.Details["duty_policy"] != "author_only" {
|
||||
t.Fatalf("approver reject details: %+v", audit.Details)
|
||||
}
|
||||
}
|
||||
@@ -90,3 +90,34 @@ func TestClassifyAccess_NoneAndPreActivation(t *testing.T) {
|
||||
t.Fatalf("pre-activation submit: %+v", preSubmit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyAccess_AuthorOnlyMatrix(t *testing.T) {
|
||||
dt := DocTaskPlan{
|
||||
Enabled: true,
|
||||
DutyPolicy: DutyPolicyAuthorOnly,
|
||||
Duties: DocDuty{AuthorUserID: 7},
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
userID int64
|
||||
action string
|
||||
wantAllow bool
|
||||
wantCode string
|
||||
}{
|
||||
{"author run", 7, DocAccessActionRun, true, ""},
|
||||
{"author edit", 7, DocAccessActionEdit, true, ""},
|
||||
{"author submit", 7, DocAccessActionSubmit, true, ""},
|
||||
{"author review", 7, DocAccessActionReview, true, ""},
|
||||
{"author approve", 7, DocAccessActionApprove, true, ""},
|
||||
{"other run denied", 2, DocAccessActionRun, false, AccessCodeForbiddenDuty},
|
||||
{"other review denied", 2, DocAccessActionReview, false, AccessCodeForbiddenDuty},
|
||||
{"other approve denied", 3, DocAccessActionApprove, false, AccessCodeForbiddenDuty},
|
||||
{"creator cannot elevate", 9, DocAccessActionApprove, false, AccessCodeForbiddenDuty},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := classifyAccess(dt, tc.action, tc.userID, tc.userID == 9, 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,3 +56,36 @@ func TestEvaluateStageReadiness_DutyModes(t *testing.T) {
|
||||
t.Fatalf("approved full + unmanaged none should be ready, issues=%+v", issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateStageReadiness_AuthorOnlyIsManaged(t *testing.T) {
|
||||
t.Parallel()
|
||||
stageDef := &workflowdef.StageDef{
|
||||
Key: "V0.1_triage",
|
||||
DocTasks: []workflowdef.DocTaskDef{
|
||||
{Key: "intake", BlockingWhenEnabled: true, CreatorConfigurable: true},
|
||||
},
|
||||
}
|
||||
plan := StagePlan{
|
||||
StageKey: "V0.1_triage",
|
||||
Enabled: true,
|
||||
DocTasks: []DocTaskPlan{{
|
||||
TaskKey: "intake", Enabled: true,
|
||||
DutyPolicy: DutyPolicyAuthorOnly,
|
||||
Duties: DocDuty{AuthorUserID: 7},
|
||||
}},
|
||||
}
|
||||
ready, issues, items := evaluateStageReadiness(plan, stageDef, map[string]string{})
|
||||
if ready {
|
||||
t.Fatal("author_only blocking doc without approval must keep gate closed")
|
||||
}
|
||||
if len(issues) != 1 || issues[0].TaskKey != "intake" {
|
||||
t.Fatalf("issues=%+v", issues)
|
||||
}
|
||||
if len(items) != 1 || !items[0].ApprovalManaged || !items[0].Required {
|
||||
t.Fatalf("author_only must be approval-managed: %+v", items)
|
||||
}
|
||||
ready, _, _ = evaluateStageReadiness(plan, stageDef, map[string]string{"intake": "approved"})
|
||||
if !ready {
|
||||
t.Fatal("approved author_only doc should open the gate")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,6 +265,56 @@ func mustActivateTestPlan(t *testing.T, svc *Service, projectID, userID int64) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStageAccess_AuthorOnlyAuthorCanAct(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newMemStore()
|
||||
_ = SeedProjectMeta(store, 510, LifecyclePlanning, "", 1, 0)
|
||||
svc := NewService(store, testResolver(), nil, nil)
|
||||
svc.SetAuthorOnlyPolicySource(&fakeAuthorOnlyPolicy{allowed: true, revision: 1})
|
||||
svc.SetActiveMemberLookup(&mapMemberLookup{ids: map[int64]struct{}{7: {}}})
|
||||
|
||||
if _, err := svc.SaveWorkflowPlan(ctx, 510, 1, true, SavePlanInput{
|
||||
Plan: authorOnlyPlan(7), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
preview, err := svc.ActivationPreview(ctx, 510, 1, true)
|
||||
if err != nil {
|
||||
t.Fatalf("preview: %v", err)
|
||||
}
|
||||
if _, err := svc.Activate(ctx, 510, 1, true, ActivateInput{
|
||||
ConfirmationToken: preview.ConfirmationToken,
|
||||
ExpectedProjectVersion: preview.ExpectedProjectVersion,
|
||||
ExpectedPlanRevision: preview.ExpectedPlanRevision,
|
||||
}); err != nil {
|
||||
t.Fatalf("activate: %v", err)
|
||||
}
|
||||
|
||||
author, err := svc.ResolveStageAccess(ctx, 510, 7, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !author.TaskKeyAllowed("intake") {
|
||||
t.Fatalf("author_only author must act on intake, got %+v", author)
|
||||
}
|
||||
if !author.StageKeyAllowed("V0.1_triage") {
|
||||
t.Fatal("author_only author must access active stage")
|
||||
}
|
||||
|
||||
outsider, _ := svc.ResolveStageAccess(ctx, 510, 99, false)
|
||||
if outsider.TaskKeyAllowed("intake") {
|
||||
t.Fatal("non-author must not act on author_only intake")
|
||||
}
|
||||
d := svc.CheckDocTaskAccess(ctx, 510, 7, "intake", DocAccessActionApprove, false)
|
||||
if !d.Allowed {
|
||||
t.Fatalf("author_only author must approve: %+v", d)
|
||||
}
|
||||
d = svc.CheckDocTaskAccess(ctx, 510, 99, "intake", DocAccessActionApprove, false)
|
||||
if d.Allowed || d.Code != AccessCodeForbiddenDuty {
|
||||
t.Fatalf("outsider approve denied: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// errGoverningStore 仅覆盖治理查询失败路径。
|
||||
type errGoverningStore struct {
|
||||
Store
|
||||
|
||||
Reference in New Issue
Block a user