Files
Agent-System/internal/projectinstance/access_governing_pg_test.go
T
agent-bot ee1e53b510
CI / go-vet (pull_request) Failing after 10s
CI / backup-restore-drill (pull_request) Failing after 25s
CI / web (pull_request) Successful in 33s
fix(tests): per-package test databases + legitimate approval fixtures (issue #2 close)
Root causes of the remaining 5 CI PG failures (and the hidden flaky set):
1. All PG-integration packages shared ONE database under parallel package
   execution - migrations/seeds/truncates raced (nondeterministic failure
   sets, locally and in CI).
   Fix: RequireTestDatabaseURL/SkipIfNoTestDatabaseURL now derive a
   per-package database (<base>_<pkgHash>, advisory-lock-guarded
   CREATE); 9 raw-env straggler tests funnelled through the helpers;
   db-package tests call it unqualified (no import cycle).
2. Approval-chain fixtures were not production-shaped: freezeReviewSnapshot
   only honors head_snapshot_id, fake content hashes never froze a
   snapshot, so ApproverApprove rejected with 'approved requires frozen
   review_snapshot_id'. Errors were swallowed (_, _ =) hiding this.
   Fix (ownership-respecting, arch-enforced):
   - docrevision.SeedContentSnapshotForTest seeds the ledger tables it owns
   - docgov GovernanceEngine.FreezeHeadSnapshotForTest freezes head/review
     pointers on document_records (docgov-owned) and clears engine cache
   - all four integration fixtures use the pair; swallowed errors checked
3. Fixed-name fixtures collide once a package shares one DB (projects
   uq_projects_name_global): uniquified in projectinstance + server tests.
4. CAS/ctxmgr tests never bootstrapped migrations (relied on other packages
   having migrated the shared DB): explicit RunMigrationsOnceForTest.

Verified: full parallel short suite = 75 packages, 0 failures against an
isolated throwaway PG. CI skip list removed - short tests fully required.
2026-09-29 09:54:43 +00:00

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 ' || $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
}