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