修复 go test -short 在设置 TEST_DATABASE_URL 时的既有欠债: - storage/notification/docgov:移除硬编码 agent_system_dev 回退 DSN,改为 无 TEST_DATABASE_URL 即 skip;固定项目名改为 name||code 唯一化。 - docgov engine_db:改为隔离库 + 自建项目行,替换随机 projectID 撞外键的旧写法。 - server/*_pg、projectinstance/pg_integration:迁移到官方 main 工作流 (BuildDefaultPlan official-only 治理后的正确前提),补齐在任成员 (DUTY_ASSIGNEE_NOT_ACTIVE_MEMBER)、任务键 bd_intake、阶段键 V0.1_triage、 项目作用域 run record(events.ValidateEnvelope 拒绝 project<=0)、 task_run_resume 去除内嵌 dev 凭证的 config 硬编码。 - 新增 seedProjectForRun 测试辅助。 验证:fresh 隔离库(55432)上 go test -short -p 1 ./... 除 archtest (另行注册表分支修复)外全部通过;包内并行迁移竞争需 -p 1。
187 lines
7.3 KiB
Go
187 lines
7.3 KiB
Go
package server_test
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"testing"
|
||
"time"
|
||
|
||
"chat-agent-system/internal/db"
|
||
"chat-agent-system/internal/projectinstance"
|
||
"chat-agent-system/internal/projectworkflow"
|
||
"chat-agent-system/internal/workflowdef"
|
||
)
|
||
|
||
func TestPhase5_MigrationVersionGate_PG(t *testing.T) {
|
||
pool := openPGPoolWithMigrations(t)
|
||
if pool == nil {
|
||
return
|
||
}
|
||
defer pool.Close()
|
||
|
||
ctx := context.Background()
|
||
migDir := db.ResolveMigrationsDir()
|
||
expected, err := db.ExpectedMigrationCount(migDir)
|
||
if err != nil {
|
||
t.Fatalf("count migrations: %v", err)
|
||
}
|
||
current, err := db.CurrentVersion(ctx, pool)
|
||
if err != nil {
|
||
t.Fatalf("current version: %v", err)
|
||
}
|
||
if int(current) < expected {
|
||
t.Fatalf("schema version %d behind expected %d migration files", current, expected)
|
||
}
|
||
|
||
// 重复迁移应幂等(模拟备份恢复后再 Up)
|
||
if err := db.RunMigrations(ctx, pool, migDir); err != nil {
|
||
t.Fatalf("re-run migrations: %v", err)
|
||
}
|
||
current2, _ := db.CurrentVersion(ctx, pool)
|
||
if current2 != current {
|
||
t.Fatalf("migration not idempotent: %d -> %d", current, current2)
|
||
}
|
||
}
|
||
|
||
func TestPhase5_AuthzNegativeMatrix_PG(t *testing.T) {
|
||
pool := openPGPoolWithMigrations(t)
|
||
if pool == nil {
|
||
return
|
||
}
|
||
defer pool.Close()
|
||
|
||
handler, _ := setupPGGovernanceServer(t, pool)
|
||
creatorToken := loginTestUser(t, handler, "zhanggong", "123456")
|
||
outsiderToken := loginTestUser(t, handler, "lizhuguan", "123456")
|
||
|
||
createBody, _ := json.Marshal(projectworkflow.CreateProjectInput{Name: "Phase5 Authz Matrix Test"})
|
||
reqCreate := httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(createBody))
|
||
withBearerAuth(reqCreate, creatorToken)
|
||
recCreate := httptest.NewRecorder()
|
||
handler.ServeHTTP(recCreate, reqCreate)
|
||
if recCreate.Code != http.StatusCreated {
|
||
t.Fatalf("create project: %d", recCreate.Code)
|
||
}
|
||
var proj projectworkflow.Project
|
||
_ = json.Unmarshal(recCreate.Body.Bytes(), &proj)
|
||
t.Cleanup(func() { cleanupProject(t, pool, proj.ID) })
|
||
|
||
// 访客无权读项目设置(requireProjectAccess)
|
||
reqSettings := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/settings", proj.ID), nil)
|
||
withBearerAuth(reqSettings, outsiderToken)
|
||
recSettings := httptest.NewRecorder()
|
||
handler.ServeHTTP(recSettings, reqSettings)
|
||
if recSettings.Code != http.StatusForbidden {
|
||
t.Fatalf("outsider settings expected 403, got %d", recSettings.Code)
|
||
}
|
||
|
||
// 非成员无权保存工作流规划
|
||
saveBody, _ := json.Marshal(projectinstance.SavePlanInput{
|
||
Plan: projectinstance.WorkflowPlan{WorkflowDefinitionKey: "medical_device_development", WorkflowVersion: 1},
|
||
})
|
||
reqSave := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/projects/%d/workflow-plan", proj.ID), bytes.NewReader(saveBody))
|
||
withBearerAuth(reqSave, outsiderToken)
|
||
recSave := httptest.NewRecorder()
|
||
handler.ServeHTTP(recSave, reqSave)
|
||
if recSave.Code != http.StatusForbidden {
|
||
t.Fatalf("outsider save plan expected 403, got %d", recSave.Code)
|
||
}
|
||
|
||
// 未认证请求
|
||
reqAnon := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/settings", proj.ID), nil)
|
||
recAnon := httptest.NewRecorder()
|
||
handler.ServeHTTP(recAnon, reqAnon)
|
||
if recAnon.Code != http.StatusUnauthorized {
|
||
t.Fatalf("anonymous settings expected 401, got %d", recAnon.Code)
|
||
}
|
||
}
|
||
|
||
func TestPhase5_ActivateIdempotencyConflict_PG(t *testing.T) {
|
||
pool := openPGPoolWithMigrations(t)
|
||
if pool == nil {
|
||
return
|
||
}
|
||
defer pool.Close()
|
||
|
||
ctx := context.Background()
|
||
handler, _ := setupPGGovernanceServer(t, pool)
|
||
seedOfficialWorkflowManifest(t, pool, workflowdef.OfficialKeyMain)
|
||
|
||
creatorToken := loginTestUser(t, handler, "zhanggong", "123456")
|
||
creatorID := fetchAuthUserID(t, handler, creatorToken)
|
||
reviewerID := fetchAuthUserID(t, handler, loginTestUser(t, handler, "lizhuguan", "123456"))
|
||
approverID := fetchAuthUserID(t, handler, loginTestUser(t, handler, "wangzong", "123456"))
|
||
|
||
var projectID int64
|
||
code := "p5-idem-" + time.Now().Format("150405.000000")
|
||
if 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, 'Phase5 Idempotency Test ' || $1, 'active', $2, 'planning', '', 1, 0) RETURNING id
|
||
`, code, creatorID).Scan(&projectID); err != nil {
|
||
t.Fatalf("insert project: %v", err)
|
||
}
|
||
ensureProjectMembers(t, pool, projectID, map[int64]string{creatorID: "owner", reviewerID: "reviewer", approverID: "approver"})
|
||
t.Cleanup(func() { cleanupProject(t, pool, projectID) })
|
||
|
||
reqDefault := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/workflow-plan/default?workflow_key=%s&version=0", projectID, workflowdef.OfficialKeyMain), nil)
|
||
withBearerAuth(reqDefault, creatorToken)
|
||
recDefault := httptest.NewRecorder()
|
||
handler.ServeHTTP(recDefault, reqDefault)
|
||
if recDefault.Code != http.StatusOK {
|
||
t.Fatalf("default plan: %d %s", recDefault.Code, recDefault.Body.String())
|
||
}
|
||
var plan projectinstance.WorkflowPlan
|
||
if err := json.Unmarshal(recDefault.Body.Bytes(), &plan); err != nil {
|
||
t.Fatalf("decode 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,
|
||
}
|
||
}
|
||
}
|
||
saveBody, _ := json.Marshal(projectinstance.SavePlanInput{Plan: plan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1})
|
||
reqSave := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/projects/%d/workflow-plan", projectID), bytes.NewReader(saveBody))
|
||
withBearerAuth(reqSave, creatorToken)
|
||
recSave := httptest.NewRecorder()
|
||
handler.ServeHTTP(recSave, reqSave)
|
||
if recSave.Code != http.StatusOK {
|
||
t.Fatalf("save plan: %d %s", recSave.Code, recSave.Body.String())
|
||
}
|
||
|
||
reqPreview := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/projects/%d/activation-preview", projectID), nil)
|
||
withBearerAuth(reqPreview, creatorToken)
|
||
recPreview := httptest.NewRecorder()
|
||
handler.ServeHTTP(recPreview, reqPreview)
|
||
if recPreview.Code != http.StatusOK {
|
||
t.Fatalf("activation preview: %d %s", recPreview.Code, recPreview.Body.String())
|
||
}
|
||
var preview projectinstance.ActivationPreview
|
||
_ = json.Unmarshal(recPreview.Body.Bytes(), &preview)
|
||
activateBody, _ := json.Marshal(projectinstance.ActivateInput{
|
||
ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion,
|
||
ExpectedPlanRevision: preview.ExpectedPlanRevision,
|
||
})
|
||
reqActivate := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/projects/%d/workflow-instances/activate", projectID), bytes.NewReader(activateBody))
|
||
withBearerAuth(reqActivate, creatorToken)
|
||
recActivate := httptest.NewRecorder()
|
||
handler.ServeHTTP(recActivate, reqActivate)
|
||
if recActivate.Code != http.StatusOK {
|
||
t.Fatalf("first activate: %d %s", recActivate.Code, recActivate.Body.String())
|
||
}
|
||
|
||
// 二次激活应冲突
|
||
reqActivate2 := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/projects/%d/workflow-instances/activate", projectID), bytes.NewReader(activateBody))
|
||
withBearerAuth(reqActivate2, creatorToken)
|
||
recActivate2 := httptest.NewRecorder()
|
||
handler.ServeHTTP(recActivate2, reqActivate2)
|
||
if recActivate2.Code != http.StatusConflict {
|
||
t.Fatalf("second activate expected 409, got %d %s", recActivate2.Code, recActivate2.Body.String())
|
||
}
|
||
}
|