Open member free-lane for chat initiation; auto-claim on author view; harden lease persist/ownership with readable DOC_BUSY; add notification inbox and supplement-impact HTTP decision APIs wired to composition root.
257 lines
10 KiB
Go
257 lines
10 KiB
Go
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', '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
|
|
}
|