diff --git a/internal/docgov/state_machine_duty_policy_test.go b/internal/docgov/state_machine_duty_policy_test.go new file mode 100644 index 00000000..f55b9c19 --- /dev/null +++ b/internal/docgov/state_machine_duty_policy_test.go @@ -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) + } +} diff --git a/internal/projectinstance/duty_action_test.go b/internal/projectinstance/duty_action_test.go index 52642c48..7cffbbba 100644 --- a/internal/projectinstance/duty_action_test.go +++ b/internal/projectinstance/duty_action_test.go @@ -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) + } + } +} diff --git a/internal/projectinstance/gate_test.go b/internal/projectinstance/gate_test.go index be9406e4..6d519253 100644 --- a/internal/projectinstance/gate_test.go +++ b/internal/projectinstance/gate_test.go @@ -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") + } +} diff --git a/internal/projectinstance/stage_access_test.go b/internal/projectinstance/stage_access_test.go index b58a35dc..d246415c 100644 --- a/internal/projectinstance/stage_access_test.go +++ b/internal/projectinstance/stage_access_test.go @@ -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