enforceChatProjectAccess before allowAnyStageAccess on GET /context/project so activated projects are no longer readable by arbitrary authenticated users. Align failing gate fixtures with official workflow keys, isolated TEST_DATABASE_URL, unique project names (00057), duty membership, and in-memory bizdev-cli envs. Co-authored-by: Cursor <cursoragent@cursor.com>
210 lines
6.8 KiB
Go
210 lines
6.8 KiB
Go
package projectinstance_test
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"chat-agent-system/internal/db"
|
|
"chat-agent-system/internal/docgov"
|
|
"chat-agent-system/internal/notification"
|
|
"chat-agent-system/internal/projectinstance"
|
|
"chat-agent-system/internal/projectworkflow"
|
|
"chat-agent-system/internal/workflowdef"
|
|
)
|
|
|
|
func openPGPool(t *testing.T) *pgxpool.Pool {
|
|
t.Helper()
|
|
dsn := db.SkipIfNoTestDatabaseURL(t)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
pool, err := pgxpool.New(ctx, dsn)
|
|
if err != nil {
|
|
t.Skip(err)
|
|
}
|
|
if err := pool.Ping(ctx); err != nil {
|
|
pool.Close()
|
|
t.Skip(err)
|
|
}
|
|
migCtx, migCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer migCancel()
|
|
if err := db.RunMigrations(migCtx, pool, db.ResolveMigrationsDir()); err != nil {
|
|
pool.Close()
|
|
t.Skipf("migration failed: %v", err)
|
|
}
|
|
if !projectinstance.TablesReady(ctx, pool) {
|
|
pool.Close()
|
|
t.Skip("project_workflow_instances not migrated")
|
|
}
|
|
if !workflowdef.TablesReady(ctx, pool) {
|
|
pool.Close()
|
|
t.Skip("workflow_definitions not migrated")
|
|
}
|
|
return pool
|
|
}
|
|
|
|
func TestPG_ActivationAndStageAdvance(t *testing.T) {
|
|
pool := openPGPool(t)
|
|
defer pool.Close()
|
|
ctx := context.Background()
|
|
|
|
// BuildDefaultPlan only accepts official keys; seed main.v2 like sibling PG tests.
|
|
wfStore := workflowdef.NewStore(pool)
|
|
def, err := workflowdef.LoadManifestDefinition("main.v2.json")
|
|
if err != nil {
|
|
t.Fatalf("load main.v2 manifest: %v", err)
|
|
}
|
|
if err := wfStore.UpsertPublishedSeed(ctx, workflowdef.CreateDefinitionInput{
|
|
Key: workflowdef.OfficialKeyMain,
|
|
Name: "商务对接流程",
|
|
Definition: def,
|
|
}, 1); err != nil {
|
|
t.Fatalf("seed official main workflow: %v", err)
|
|
}
|
|
|
|
var authorID, reviewerID, approverID int64
|
|
rows, err := pool.Query(ctx, `SELECT id FROM users ORDER BY id ASC LIMIT 3`)
|
|
if err != nil {
|
|
t.Fatalf("list users: %v", err)
|
|
}
|
|
defer rows.Close()
|
|
ids := make([]int64, 0, 3)
|
|
for rows.Next() {
|
|
var id int64
|
|
if scanErr := rows.Scan(&id); scanErr != nil {
|
|
t.Fatalf("scan user: %v", scanErr)
|
|
}
|
|
ids = append(ids, id)
|
|
}
|
|
if len(ids) < 3 {
|
|
t.Skip("need at least 3 user rows for duty assignees")
|
|
}
|
|
authorID, reviewerID, approverID = ids[0], ids[1], ids[2]
|
|
|
|
pwStore := projectworkflow.NewStore(pool)
|
|
instStore := projectinstance.NewStore(pool)
|
|
docChecker := projectinstance.NewDocGovChecker(docgov.NewGovernanceEngine(pool))
|
|
svc := projectinstance.NewService(instStore, wfStore, pwStore, docChecker)
|
|
|
|
var projectID int64
|
|
code := "pg-p3-" + 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, "PG Phase3 Test "+code, authorID).Scan(&projectID)
|
|
if err != nil {
|
|
t.Fatalf("insert project: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_, _ = 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)
|
|
}
|
|
intakeTask := ""
|
|
for i := range plan.Stages {
|
|
for j := range plan.Stages[i].DocTasks {
|
|
tk := plan.Stages[i].DocTasks[j].TaskKey
|
|
plan.Stages[i].DocTasks[j].Duties = projectinstance.DocDuty{
|
|
AuthorUserID: authorID, ReviewerUserID: reviewerID, ApproverUserID: approverID,
|
|
}
|
|
if tk == "intake" || tk == "bd_intake" {
|
|
intakeTask = tk
|
|
} else {
|
|
// Keep a single blocking intake path so stage readiness is deterministic.
|
|
plan.Stages[i].DocTasks[j].Enabled = false
|
|
}
|
|
}
|
|
}
|
|
if intakeTask == "" {
|
|
t.Fatal("official main plan missing intake/bd_intake task")
|
|
}
|
|
|
|
_, err = svc.SaveWorkflowPlan(ctx, projectID, authorID, true, projectinstance.SavePlanInput{
|
|
Plan: *plan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("save plan: %v", err)
|
|
}
|
|
preview, err := svc.ActivationPreview(ctx, projectID, authorID, true)
|
|
if err != nil {
|
|
t.Fatalf("preview: %v", err)
|
|
}
|
|
activated, err := svc.Activate(ctx, projectID, authorID, true, projectinstance.ActivateInput{
|
|
ConfirmationToken: preview.ConfirmationToken,
|
|
ExpectedProjectVersion: preview.ExpectedProjectVersion,
|
|
ExpectedPlanRevision: preview.ExpectedPlanRevision,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("activate: %v", err)
|
|
}
|
|
|
|
docID := "doc_pg_p3_intake_" + code
|
|
engine := docgov.NewGovernanceEngine(pool)
|
|
_, err = engine.CreateOrGetDocument(ctx, docID, projectID, intakeTask, "客户需求采集", "V0.1", "", authorID, "张", reviewerID, "李", approverID, "王")
|
|
if err != nil {
|
|
t.Fatalf("create doc: %v", err)
|
|
}
|
|
_ = engine.MarkDocumentContentUpdated(ctx, docID, "hash_pg_p3", false)
|
|
_, _ = engine.SubmitForReview(ctx, docID, authorID, "author", "张")
|
|
_, _ = engine.ReviewApprove(ctx, docID, reviewerID, "reviewer", "李", "ok")
|
|
_, err = engine.ApproverApprove(ctx, docID, approverID, "approver", "王", "ok")
|
|
if err != nil {
|
|
t.Fatalf("approve doc: %v", err)
|
|
}
|
|
|
|
approvedResult, err := svc.HandleDocumentApproved(ctx, projectID, intakeTask)
|
|
if err != nil {
|
|
t.Fatalf("handle document approved: %v", err)
|
|
}
|
|
if approvedResult == nil || !approvedResult.StageReady {
|
|
t.Fatalf("expected stage ready after approval, got %+v", approvedResult)
|
|
}
|
|
|
|
notifySvc := notification.NewService(pool)
|
|
if notifySvc.Available() {
|
|
created, emitErr := notifySvc.EmitStageReadyToAdvance(ctx, authorID, notification.StageReadyPayload{
|
|
ProjectID: projectID,
|
|
WorkflowInstanceID: approvedResult.InstanceID,
|
|
StageInstanceID: approvedResult.StageInstanceID,
|
|
StageKey: approvedResult.StageKey,
|
|
StageName: approvedResult.StageName,
|
|
ConfigRevision: approvedResult.ConfigRevision,
|
|
RequiredDocCount: approvedResult.RequiredDocCount,
|
|
ApprovedDocCount: approvedResult.ApprovedDocCount,
|
|
})
|
|
if emitErr != nil {
|
|
t.Fatalf("emit stage ready notification: %v", emitErr)
|
|
}
|
|
if !created {
|
|
t.Fatal("expected stage ready notification to be created")
|
|
}
|
|
}
|
|
|
|
stageKey := approvedResult.StageKey
|
|
if stageKey == "" {
|
|
stageKey = "V0.1_triage"
|
|
}
|
|
readiness, err := svc.GetStageReadiness(ctx, activated.Instance.ID, stageKey, authorID, true)
|
|
if err != nil {
|
|
t.Fatalf("readiness: %v", err)
|
|
}
|
|
if !readiness.Ready {
|
|
t.Fatalf("expected ready stage, issues=%+v tasks=%+v", readiness.BlockingIssues, readiness.DocTasks)
|
|
}
|
|
|
|
advanced, err := svc.AdvanceStage(ctx, activated.Instance.ID, authorID, true, projectinstance.AdvanceStageInput{
|
|
StageKey: stageKey, ExpectedConfigRevision: activated.Instance.ConfigRevision,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("advance: %v", err)
|
|
}
|
|
if advanced.NextStageKey == "" && !advanced.InstanceDone {
|
|
t.Fatalf("expected next stage or done, got %+v", advanced)
|
|
}
|
|
}
|