test(projectinstance): cover author_only planning and activation
Verify resolve/normalize/hash stability, policy gating, stale token rejection, and HTTP error mapping for the new duty policy path. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -100,9 +100,91 @@ func TestCountFullDutyDocs(t *testing.T) {
|
||||
{Enabled: true, Duties: DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3}},
|
||||
{Enabled: true},
|
||||
{Enabled: false, Duties: DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3}},
|
||||
{Enabled: true, DutyPolicy: DutyPolicyAuthorOnly, Duties: DocDuty{AuthorUserID: 9}},
|
||||
},
|
||||
}}}
|
||||
if n := countFullDutyDocs(plan); n != 1 {
|
||||
t.Fatalf("countFullDutyDocs=%d want 1", n)
|
||||
if n := countFullDutyDocs(plan); n != 2 {
|
||||
t.Fatalf("countFullDutyDocs=%d want 2 (full + author_only)", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDocTaskDuty(t *testing.T) {
|
||||
t.Parallel()
|
||||
mode, eff, only := ResolveDocTaskDuty(DocTaskPlan{Enabled: false, DutyPolicy: DutyPolicyAuthorOnly, Duties: DocDuty{AuthorUserID: 1}})
|
||||
if mode != DutyModeNone || only || eff != (DocDuty{}) {
|
||||
t.Fatalf("disabled: mode=%s only=%v eff=%+v", mode, only, eff)
|
||||
}
|
||||
mode, eff, only = ResolveDocTaskDuty(DocTaskPlan{
|
||||
Enabled: true, DutyPolicy: DutyPolicyAuthorOnly, Duties: DocDuty{AuthorUserID: 7},
|
||||
})
|
||||
if mode != DutyModeFull || !only || eff != (DocDuty{AuthorUserID: 7, ReviewerUserID: 7, ApproverUserID: 7}) {
|
||||
t.Fatalf("author_only: mode=%s only=%v eff=%+v", mode, only, eff)
|
||||
}
|
||||
mode, _, only = ResolveDocTaskDuty(DocTaskPlan{Enabled: true, DutyPolicy: DutyPolicyAuthorOnly})
|
||||
if mode != DutyModePartial || !only {
|
||||
t.Fatalf("author_only missing author: mode=%s only=%v", mode, only)
|
||||
}
|
||||
mode, _, only = ResolveDocTaskDuty(DocTaskPlan{Enabled: true, DutyPolicy: "weird"})
|
||||
if mode != DutyModePartial || only {
|
||||
t.Fatalf("unknown policy: mode=%s only=%v", mode, only)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAuthorOnlyDuties(t *testing.T) {
|
||||
t.Parallel()
|
||||
same := normalizeDocTaskDuties(DocTaskPlan{
|
||||
Enabled: true, DutyPolicy: DutyPolicyAuthorOnly,
|
||||
Duties: DocDuty{AuthorUserID: 5, ReviewerUserID: 5, ApproverUserID: 5},
|
||||
})
|
||||
if same.Duties.ReviewerUserID != 0 || same.Duties.ApproverUserID != 0 || same.Duties.AuthorUserID != 5 {
|
||||
t.Fatalf("same-as-author must clear derived slots: %+v", same.Duties)
|
||||
}
|
||||
conflict := normalizeDocTaskDuties(DocTaskPlan{
|
||||
Enabled: true, DutyPolicy: DutyPolicyAuthorOnly,
|
||||
Duties: DocDuty{AuthorUserID: 5, ReviewerUserID: 8},
|
||||
})
|
||||
if conflict.Duties.ReviewerUserID != 8 {
|
||||
t.Fatalf("conflicting reviewer must remain for validation: %+v", conflict.Duties)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAuthorOnlyDocTaskDuties(t *testing.T) {
|
||||
t.Parallel()
|
||||
optional := workflowdef.DocTaskDef{Key: "research", Title: "调研", CreatorConfigurable: true}
|
||||
ok := validateDocTaskDuties(DocTaskPlan{
|
||||
TaskKey: "research", Enabled: true, DutyPolicy: DutyPolicyAuthorOnly,
|
||||
Duties: DocDuty{AuthorUserID: 3},
|
||||
}, optional)
|
||||
if len(ok) != 0 {
|
||||
t.Fatalf("valid author_only: %+v", ok)
|
||||
}
|
||||
missing := validateDocTaskDuties(DocTaskPlan{
|
||||
TaskKey: "research", Enabled: true, DutyPolicy: DutyPolicyAuthorOnly,
|
||||
}, optional)
|
||||
if len(missing) != 1 || missing[0].Code != issueAuthorOnlyAuthorRequired {
|
||||
t.Fatalf("missing author: %+v", missing)
|
||||
}
|
||||
conflict := validateDocTaskDuties(DocTaskPlan{
|
||||
TaskKey: "research", Enabled: true, DutyPolicy: DutyPolicyAuthorOnly,
|
||||
Duties: DocDuty{AuthorUserID: 3, ReviewerUserID: 9},
|
||||
}, optional)
|
||||
if len(conflict) != 1 || conflict[0].Code != issueAuthorOnlyDutyConflict {
|
||||
t.Fatalf("conflict: %+v", conflict)
|
||||
}
|
||||
unknown := validateDocTaskDuties(DocTaskPlan{
|
||||
TaskKey: "research", Enabled: true, DutyPolicy: "nope",
|
||||
Duties: DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3},
|
||||
}, optional)
|
||||
if len(unknown) != 1 || unknown[0].Code != issueUnknownDutyPolicy {
|
||||
t.Fatalf("unknown: %+v", unknown)
|
||||
}
|
||||
// separated A/A/A still rejected
|
||||
sep := validateDocTaskDuties(DocTaskPlan{
|
||||
TaskKey: "research", Enabled: true,
|
||||
Duties: DocDuty{AuthorUserID: 1, ReviewerUserID: 1, ApproverUserID: 1},
|
||||
}, optional)
|
||||
if len(sep) != 1 || sep[0].Code != issueSeparationOfDutyViolation {
|
||||
t.Fatalf("separated A/A/A: %+v", sep)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package projectinstance
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"chat-agent-system/internal/workflowdef"
|
||||
)
|
||||
|
||||
func TestComputePlanHash_LegacySnapshotStableWithoutDutyPolicy(t *testing.T) {
|
||||
t.Parallel()
|
||||
plan := WorkflowPlan{
|
||||
WorkflowDefinitionKey: workflowdef.OfficialKeyMain,
|
||||
WorkflowVersion: 1,
|
||||
Stages: []StagePlan{{
|
||||
StageKey: "s1", Enabled: true,
|
||||
DocTasks: []DocTaskPlan{{
|
||||
TaskKey: "intake", Enabled: true,
|
||||
Duties: DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3},
|
||||
}},
|
||||
}},
|
||||
}
|
||||
h1, err := computePlanHash(plan)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// empty DutyPolicy must not appear in JSON (omitempty) — hash must match explicit separated unset
|
||||
plan2 := plan
|
||||
plan2.Stages[0].DocTasks[0].DutyPolicy = ""
|
||||
h2, err := computePlanHash(plan2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if h1 != h2 {
|
||||
t.Fatalf("empty duty_policy must be hash-stable: %s vs %s", h1, h2)
|
||||
}
|
||||
plan3 := plan
|
||||
plan3.Stages[0].DocTasks[0].DutyPolicy = DutyPolicyAuthorOnly
|
||||
h3, err := computePlanHash(plan3)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if h3 == h1 {
|
||||
t.Fatal("author_only duty_policy must change plan hash")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package projectinstance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type fakeAuthorOnlyPolicy struct {
|
||||
mu sync.Mutex
|
||||
allowed bool
|
||||
revision int64
|
||||
failGet bool
|
||||
}
|
||||
|
||||
func (f *fakeAuthorOnlyPolicy) GetAuthorOnlyApprovalPolicy(_ context.Context) (AuthorOnlyPolicySnapshot, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.failGet {
|
||||
return AuthorOnlyPolicySnapshot{}, errors.New("boom")
|
||||
}
|
||||
return AuthorOnlyPolicySnapshot{Allowed: f.allowed, Revision: f.revision}, nil
|
||||
}
|
||||
|
||||
func (f *fakeAuthorOnlyPolicy) LockAuthorOnlyApprovalPolicy(ctx context.Context, _ pgx.Tx) (AuthorOnlyPolicySnapshot, error) {
|
||||
return f.GetAuthorOnlyApprovalPolicy(ctx)
|
||||
}
|
||||
|
||||
func (f *fakeAuthorOnlyPolicy) set(allowed bool, revision int64) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.allowed = allowed
|
||||
f.revision = revision
|
||||
}
|
||||
|
||||
func authorOnlyPlan(authorID int64) WorkflowPlan {
|
||||
plan := validPlan()
|
||||
plan.Stages[0].DocTasks[0].DutyPolicy = DutyPolicyAuthorOnly
|
||||
plan.Stages[0].DocTasks[0].Duties = DocDuty{AuthorUserID: authorID}
|
||||
return plan
|
||||
}
|
||||
|
||||
func TestService_AuthorOnlySavePreviewActivate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newMemStore()
|
||||
_ = SeedProjectMeta(store, 90, LifecyclePlanning, "", 1, 0)
|
||||
svc := NewService(store, testResolver(), nil, nil)
|
||||
pol := &fakeAuthorOnlyPolicy{allowed: true, revision: 3}
|
||||
svc.SetAuthorOnlyPolicySource(pol)
|
||||
svc.SetActiveMemberLookup(&mapMemberLookup{ids: map[int64]struct{}{1: {}}})
|
||||
|
||||
settings, err := svc.SaveWorkflowPlan(ctx, 90, 1, true, SavePlanInput{
|
||||
Plan: authorOnlyPlan(1), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveWorkflowPlan: %v", err)
|
||||
}
|
||||
if settings.Plan == nil || settings.Plan.Stages[0].DocTasks[0].DutyPolicy != DutyPolicyAuthorOnly {
|
||||
t.Fatalf("expected author_only plan, got %+v", settings.Plan)
|
||||
}
|
||||
if !settings.AuthorOnlyPolicy.Allowed || settings.AuthorOnlyPolicy.Revision != 3 {
|
||||
t.Fatalf("settings capability: %+v", settings.AuthorOnlyPolicy)
|
||||
}
|
||||
|
||||
preview, err := svc.ActivationPreview(ctx, 90, 1, true)
|
||||
if err != nil {
|
||||
t.Fatalf("ActivationPreview: %v", err)
|
||||
}
|
||||
if preview.Summary.AuthorOnlyDocTasks != 1 || preview.Summary.GovernedDocTasks != 1 {
|
||||
t.Fatalf("preview summary: %+v", preview.Summary)
|
||||
}
|
||||
if len(preview.Summary.AuthorOnlyDocuments) != 1 || preview.Summary.AuthorOnlyDocuments[0].AuthorUserID != 1 {
|
||||
t.Fatalf("author_only docs: %+v", preview.Summary.AuthorOnlyDocuments)
|
||||
}
|
||||
|
||||
result, err := svc.Activate(ctx, 90, 1, true, ActivateInput{
|
||||
ConfirmationToken: preview.ConfirmationToken,
|
||||
ExpectedProjectVersion: preview.ExpectedProjectVersion,
|
||||
ExpectedPlanRevision: preview.ExpectedPlanRevision,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Activate: %v", err)
|
||||
}
|
||||
if result.LifecycleStatus != LifecycleActive {
|
||||
t.Fatalf("result: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_AuthorOnlyRejectedWhenPolicyDisabled(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newMemStore()
|
||||
_ = SeedProjectMeta(store, 91, LifecyclePlanning, "", 1, 0)
|
||||
svc := NewService(store, testResolver(), nil, nil)
|
||||
svc.SetAuthorOnlyPolicySource(&fakeAuthorOnlyPolicy{allowed: false, revision: 0})
|
||||
svc.SetActiveMemberLookup(&mapMemberLookup{ids: map[int64]struct{}{1: {}}})
|
||||
|
||||
_, err := svc.SaveWorkflowPlan(ctx, 91, 1, true, SavePlanInput{
|
||||
Plan: authorOnlyPlan(1), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1,
|
||||
})
|
||||
if !errors.Is(err, ErrAuthorOnlyPolicyDisabled) {
|
||||
t.Fatalf("expected ErrAuthorOnlyPolicyDisabled, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_AuthorOnlyTokenInvalidatedWhenPolicyChanges(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newMemStore()
|
||||
_ = SeedProjectMeta(store, 92, LifecyclePlanning, "", 1, 0)
|
||||
svc := NewService(store, testResolver(), nil, nil)
|
||||
pol := &fakeAuthorOnlyPolicy{allowed: true, revision: 1}
|
||||
svc.SetAuthorOnlyPolicySource(pol)
|
||||
svc.SetActiveMemberLookup(&mapMemberLookup{ids: map[int64]struct{}{1: {}}})
|
||||
|
||||
if _, err := svc.SaveWorkflowPlan(ctx, 92, 1, true, SavePlanInput{
|
||||
Plan: authorOnlyPlan(1), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
preview, err := svc.ActivationPreview(ctx, 92, 1, true)
|
||||
if err != nil {
|
||||
t.Fatalf("preview: %v", err)
|
||||
}
|
||||
pol.set(false, 2)
|
||||
_, err = svc.Activate(ctx, 92, 1, true, ActivateInput{
|
||||
ConfirmationToken: preview.ConfirmationToken,
|
||||
ExpectedProjectVersion: preview.ExpectedProjectVersion,
|
||||
ExpectedPlanRevision: preview.ExpectedPlanRevision,
|
||||
})
|
||||
if !errors.Is(err, ErrAuthorOnlyPolicyDisabled) && !errors.Is(err, ErrAuthorOnlyPolicyChanged) {
|
||||
t.Fatalf("expected policy disabled/changed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_AuthorOnlyTokenRevisionMismatch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newMemStore()
|
||||
_ = SeedProjectMeta(store, 93, LifecyclePlanning, "", 1, 0)
|
||||
svc := NewService(store, testResolver(), nil, nil)
|
||||
pol := &fakeAuthorOnlyPolicy{allowed: true, revision: 5}
|
||||
svc.SetAuthorOnlyPolicySource(pol)
|
||||
svc.SetActiveMemberLookup(&mapMemberLookup{ids: map[int64]struct{}{1: {}}})
|
||||
|
||||
if _, err := svc.SaveWorkflowPlan(ctx, 93, 1, true, SavePlanInput{
|
||||
Plan: authorOnlyPlan(1), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
preview, err := svc.ActivationPreview(ctx, 93, 1, true)
|
||||
if err != nil {
|
||||
t.Fatalf("preview: %v", err)
|
||||
}
|
||||
// 总开关再次开启但 revision 前进(false→true 也会 bump),旧 token 必须作废
|
||||
pol.set(true, 6)
|
||||
_, err = svc.Activate(ctx, 93, 1, true, ActivateInput{
|
||||
ConfirmationToken: preview.ConfirmationToken,
|
||||
ExpectedProjectVersion: preview.ExpectedProjectVersion,
|
||||
ExpectedPlanRevision: preview.ExpectedPlanRevision,
|
||||
})
|
||||
if !errors.Is(err, ErrAuthorOnlyPolicyChanged) {
|
||||
t.Fatalf("expected ErrAuthorOnlyPolicyChanged, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_AuthorOnlyForbiddenForNonCreator(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newMemStore()
|
||||
_ = SeedProjectMeta(store, 94, LifecyclePlanning, "", 1, 0)
|
||||
svc := NewService(store, testResolver(), nil, nil)
|
||||
svc.SetAuthorOnlyPolicySource(&fakeAuthorOnlyPolicy{allowed: true, revision: 1})
|
||||
_, err := svc.SaveWorkflowPlan(ctx, 94, 1, false, SavePlanInput{
|
||||
Plan: authorOnlyPlan(1), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1,
|
||||
})
|
||||
if !errors.Is(err, ErrForbiddenNotCreator) {
|
||||
t.Fatalf("expected ErrForbiddenNotCreator, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewSummary_AuthorOnlySplit(t *testing.T) {
|
||||
version := optionalDutyResolver().versions[validPlan().WorkflowDefinitionKey]
|
||||
plan := optionalDutyPlan(
|
||||
DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3},
|
||||
DocDuty{},
|
||||
)
|
||||
plan.Stages[0].DocTasks[0].DutyPolicy = DutyPolicyAuthorOnly
|
||||
plan.Stages[0].DocTasks[0].Duties = DocDuty{AuthorUserID: 9}
|
||||
sum := previewSummary(plan, version)
|
||||
if sum.AuthorOnlyDocTasks != 1 || sum.GovernedDocTasks != 1 || sum.UnmanagedDocTasks != 1 {
|
||||
t.Fatalf("summary: %+v", sum)
|
||||
}
|
||||
if len(sum.AuthorOnlyDocuments) != 1 || sum.AuthorOnlyDocuments[0].AuthorUserID != 9 {
|
||||
t.Fatalf("author_only docs: %+v", sum.AuthorOnlyDocuments)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"chat-agent-system/internal/projectinstance"
|
||||
)
|
||||
|
||||
func TestWriteProjectInstanceError_AuthorOnlyCodes(t *testing.T) {
|
||||
cases := []struct {
|
||||
err error
|
||||
code int
|
||||
body string
|
||||
}{
|
||||
{projectinstance.ErrAuthorOnlyPolicyDisabled, http.StatusForbidden, "AUTHOR_ONLY_POLICY_DISABLED"},
|
||||
{projectinstance.ErrAuthorOnlyPolicyChanged, http.StatusConflict, "AUTHOR_ONLY_POLICY_CHANGED"},
|
||||
{projectinstance.ErrAuthorOnlyPolicyUnavailable, http.StatusServiceUnavailable, "AUTHOR_ONLY_POLICY_UNAVAILABLE"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
w := httptest.NewRecorder()
|
||||
writeProjectInstanceError(w, tc.err)
|
||||
if w.Code != tc.code {
|
||||
t.Fatalf("%v: status %d want %d body=%s", tc.err, w.Code, tc.code, w.Body.String())
|
||||
}
|
||||
if !contains(w.Body.String(), tc.body) {
|
||||
t.Fatalf("%v: body %s missing %s", tc.err, w.Body.String(), tc.body)
|
||||
}
|
||||
}
|
||||
// wrapped unavailable
|
||||
w := httptest.NewRecorder()
|
||||
writeProjectInstanceError(w, errors.Join(projectinstance.ErrAuthorOnlyPolicyUnavailable, errors.New("db down")))
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("wrapped unavailable: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
return len(s) >= len(sub) && (s == sub || len(sub) == 0 ||
|
||||
(func() bool {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})())
|
||||
}
|
||||
Reference in New Issue
Block a user