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>
197 lines
6.9 KiB
Go
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)
|
|
}
|
|
}
|