package projectinstance_test import ( "context" "errors" "testing" "time" "chat-agent-system/internal/projectinstance" "chat-agent-system/internal/projectworkflow" "chat-agent-system/internal/workflowdef" ) func TestPG_GoverningAccess_NeverActiveUnrestricted(t *testing.T) { pool := openPGPool(t) defer pool.Close() ctx := context.Background() store := projectinstance.NewStore(pool) svc := projectinstance.NewService(store, workflowdef.NewStore(pool), projectworkflow.NewStore(pool), nil) var creatorID int64 err := pool.QueryRow(ctx, `SELECT id FROM users ORDER BY id ASC LIMIT 1`).Scan(&creatorID) if err != nil || creatorID <= 0 { t.Skip("need at least one user row") } var projectID int64 code := "pg-access-none-" + time.Now().Format("150405.000") err = pool.QueryRow(ctx, ` INSERT INTO projects (code, name, status, creator_user_id, lifecycle_status, active_workflow_key, workflow_selection_version, workflow_plan_revision) VALUES ($1, $2, 'active', $3, 'planning', '', 1, 0) RETURNING id `, code, "Access Never Active "+code, creatorID).Scan(&projectID) if err != nil { t.Fatalf("insert project: %v", err) } t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `DELETE FROM project_workflow_instances WHERE project_id = $1`, projectID) _, _ = pool.Exec(context.Background(), `DELETE FROM projects WHERE id = $1`, projectID) }) access, err := svc.ResolveStageAccess(ctx, projectID, creatorID, true) if err != nil { t.Fatal(err) } if access.Restricted { t.Fatal("never-activated project must be unrestricted") } d := svc.CheckDocTaskAccess(ctx, projectID, creatorID, "intake", projectinstance.DocAccessActionRun, true) if !d.Allowed { t.Fatalf("run on never-activated must allow, got %+v", d) } } func TestPG_GoverningAccess_ActiveCreatorDutyOutsider(t *testing.T) { pool := openPGPool(t) defer pool.Close() ctx := context.Background() var creatorID, reviewerID, approverID, outsiderID int64 err := pool.QueryRow(ctx, `SELECT id FROM users ORDER BY id ASC LIMIT 1`).Scan(&creatorID) if err != nil { t.Skip("need users") } _ = pool.QueryRow(ctx, `SELECT id FROM users WHERE id <> $1 ORDER BY id ASC LIMIT 1`, creatorID).Scan(&reviewerID) _ = pool.QueryRow(ctx, `SELECT id FROM users WHERE id <> $1 AND id <> $2 ORDER BY id ASC LIMIT 1`, creatorID, reviewerID).Scan(&approverID) _ = pool.QueryRow(ctx, `SELECT id FROM users WHERE id <> ALL($1::bigint[]) ORDER BY id ASC LIMIT 1`, []int64{creatorID, reviewerID, approverID}).Scan(&outsiderID) if reviewerID <= 0 || approverID <= 0 { t.Skip("need ≥3 users for separation of duty") } if outsiderID <= 0 { outsiderID = creatorID + 9_000_000 } wfStore := workflowdef.NewStore(pool) def, err := workflowdef.LoadManifestDefinition("main.v2.json") if err != nil { t.Fatalf("load manifest: %v", err) } if err := wfStore.UpsertPublishedSeed(ctx, workflowdef.CreateDefinitionInput{ Key: workflowdef.OfficialKeyMain, Name: "商务对接流程", Definition: def, }, creatorID); err != nil { t.Fatalf("seed official: %v", err) } instStore := projectinstance.NewStore(pool) svc := projectinstance.NewService(instStore, wfStore, projectworkflow.NewStore(pool), nil) var projectID int64 code := "pg-access-live-" + time.Now().Format("150405.000") err = pool.QueryRow(ctx, ` INSERT INTO projects (code, name, status, creator_user_id, lifecycle_status, active_workflow_key, workflow_selection_version, workflow_plan_revision) VALUES ($1, 'Access Live ' || $1, 'active', $2, 'planning', '', 1, 0) RETURNING id `, code, creatorID).Scan(&projectID) if err != nil { t.Fatalf("insert project: %v", err) } t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `DELETE FROM project_workflow_instances WHERE project_id = $1`, projectID) _, _ = pool.Exec(context.Background(), `DELETE FROM projects WHERE id = $1`, projectID) }) plan, err := svc.BuildDefaultPlan(ctx, workflowdef.OfficialKeyMain, 0) if err != nil { t.Fatalf("default plan: %v", err) } for i := range plan.Stages { for j := range plan.Stages[i].DocTasks { plan.Stages[i].DocTasks[j].Duties = projectinstance.DocDuty{ AuthorUserID: creatorID, ReviewerUserID: reviewerID, ApproverUserID: approverID, } } } _, err = svc.SaveWorkflowPlan(ctx, projectID, creatorID, true, projectinstance.SavePlanInput{ Plan: *plan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, }) if err != nil { var ve *projectinstance.ValidationError if errors.As(err, &ve) { t.Fatalf("save validation: %+v", ve.Issues) } t.Fatalf("save: %v plan_stages=%d", err, len(plan.Stages)) } preview, err := svc.ActivationPreview(ctx, projectID, creatorID, true) if err != nil { t.Fatalf("preview: %v", err) } if _, err := svc.Activate(ctx, projectID, creatorID, true, projectinstance.ActivateInput{ ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion, ExpectedPlanRevision: preview.ExpectedPlanRevision, }); err != nil { t.Fatalf("activate: %v", err) } creatorAccess, err := svc.ResolveStageAccess(ctx, projectID, creatorID, true) if err != nil || !creatorAccess.Restricted || !creatorAccess.TaskKeyAllowed("intake") { t.Fatalf("creator under live must allow intake, got %+v err=%v", creatorAccess, err) } dutyRun := svc.CheckDocTaskAccess(ctx, projectID, creatorID, "intake", projectinstance.DocAccessActionRun, false) if !dutyRun.Allowed { t.Fatalf("author duty must run, got %+v", dutyRun) } memberRun := svc.CheckDocTaskAccess(ctx, projectID, outsiderID, "intake", projectinstance.DocAccessActionRun, false) if !memberRun.Allowed { t.Fatalf("non-duty member must run, got %+v", memberRun) } memberSubmit := svc.CheckDocTaskAccess(ctx, projectID, outsiderID, "intake", projectinstance.DocAccessActionSubmit, false) if memberSubmit.Allowed || memberSubmit.Code != projectinstance.AccessCodeForbiddenDuty { t.Fatalf("non-duty member must not submit, got %+v", memberSubmit) } outsiderStage, _ := svc.ResolveStageAccess(ctx, projectID, outsiderID, false) if !outsiderStage.Restricted || !outsiderStage.TaskKeyAllowed("intake") { t.Fatalf("member stage must allow intake run, got %+v", outsiderStage) } } func TestPG_GoverningAccess_CompletedAndTerminatedDeny(t *testing.T) { pool := openPGPool(t) defer pool.Close() ctx := context.Background() store := projectinstance.NewStore(pool) svc := projectinstance.NewService(store, workflowdef.NewStore(pool), projectworkflow.NewStore(pool), nil) for _, status := range []string{projectinstance.InstanceCompleted, projectinstance.InstanceTerminated} { status := status t.Run(status, func(t *testing.T) { var creatorID int64 if err := pool.QueryRow(ctx, `SELECT id FROM users ORDER BY id ASC LIMIT 1`).Scan(&creatorID); err != nil { t.Skip("need users") } var projectID int64 code := "pg-access-" + status + "-" + time.Now().Format("150405.000000") err := pool.QueryRow(ctx, ` INSERT INTO projects (code, name, status, creator_user_id, lifecycle_status, active_workflow_key, workflow_selection_version, workflow_plan_revision) VALUES ($1, $2, 'active', $3, 'planning', '', 1, 0) RETURNING id `, code, "Access "+status, creatorID).Scan(&projectID) if err != nil { t.Fatalf("insert project: %v", err) } var instID int64 err = pool.QueryRow(ctx, ` INSERT INTO project_workflow_instances ( project_id, workflow_version_id, instance_no, status, config_revision, plan_hash, config_snapshot, created_by, created_at, updated_at ) VALUES ( $1, 1, 1, $2, 1, 'hash', '{"workflow_definition_key":"x","workflow_version":1,"stages":[]}'::jsonb, $3, now(), now() ) RETURNING id `, projectID, status, creatorID).Scan(&instID) if err != nil { t.Fatalf("insert instance: %v", err) } t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `DELETE FROM project_workflow_instances WHERE project_id = $1`, projectID) _, _ = pool.Exec(context.Background(), `DELETE FROM projects WHERE id = $1`, projectID) }) hasLive, err := store.HasLiveInstance(ctx, projectID) if err != nil || hasLive { t.Fatalf("HasLiveInstance must be false for %s, has=%v err=%v", status, hasLive, err) } live, err := store.GetLiveInstance(ctx, projectID) if err != nil || live != nil { t.Fatalf("GetLiveInstance must be nil for %s, got %+v err=%v", status, live, err) } gov, err := store.GetGoverningInstanceForAccess(ctx, projectID) if err != nil || gov == nil || gov.Status != status { t.Fatalf("governing must be %s, got %+v err=%v", status, gov, err) } access, err := svc.ResolveStageAccess(ctx, projectID, creatorID, true) if err != nil || !access.Restricted || access.TaskKeyAllowed("intake") { t.Fatalf("ResolveStageAccess must restrict closed, got %+v err=%v", access, err) } for _, action := range []string{projectinstance.DocAccessActionRun, projectinstance.DocAccessActionEdit} { d := svc.CheckDocTaskAccess(ctx, projectID, creatorID, "intake", action, true) if d.Allowed || d.Code != projectinstance.AccessCodeInstanceClosed { t.Fatalf("%s %s must WORKFLOW_INSTANCE_CLOSED, got %+v", status, action, d) } } }) } } func TestPG_GoverningAccess_QueryErrorFailClosed(t *testing.T) { svc := projectinstance.NewService(&errPoolStore{err: errors.New("db down")}, nil, nil, nil) _, err := svc.ResolveStageAccess(context.Background(), 1, 1, true) if err == nil { t.Fatal("ResolveStageAccess must propagate store errors") } d := svc.CheckDocTaskAccess(context.Background(), 1, 1, "intake", projectinstance.DocAccessActionRun, true) if d.Allowed || d.Code != projectinstance.AccessCodeInstanceClosed { t.Fatalf("CheckDocTaskAccess must fail-closed on store error, got %+v", d) } } type errPoolStore struct { projectinstance.Store err error } func (e *errPoolStore) GetGoverningInstanceForAccess(context.Context, int64) (*projectinstance.WorkflowInstance, error) { return nil, e.err } func (e *errPoolStore) GetLiveInstance(context.Context, int64) (*projectinstance.WorkflowInstance, error) { return nil, e.err } func (e *errPoolStore) HasLiveInstance(context.Context, int64) (bool, error) { return false, e.err }