Files
weichenandCursor 68dbe8a6b2 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>
2026-09-17 09:39:59 +00:00

197 lines
6.9 KiB
Go

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)
}
}