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) creatorToken := loginTestUser(t, handler, "zhanggong", "123456") wfSvc := workflowdef.NewService(pool, nil) wfKey := "pg_p5_idem_" + time.Now().Format("150405.000") _, ver, err := wfSvc.CreateDefinition(ctx, workflowdef.CreateDefinitionInput{ Key: wfKey, Name: "Idempotency WF", Definition: workflowdef.VersionDefinition{ SchemaVersion: 1, Stages: []workflowdef.StageDef{{ Key: "biz_intake", Name: "Intake", Position: 0, DocTasks: []workflowdef.DocTaskDef{{ Key: "intake", Required: true, Blocking: true, DefaultEnabled: true, OutputDocType: "intake", OutputDocTitle: "Customer Intake", }}, }}, }, }, 1) if err != nil { t.Fatalf("create wf: %v", err) } _, _ = wfSvc.PublishVersion(ctx, ver.WorkflowID, ver.Version, 1) createBody, _ := json.Marshal(projectworkflow.CreateProjectInput{Name: "Phase5 Idempotency Test"}) reqCreate := httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(createBody)) withBearerAuth(reqCreate, creatorToken) recCreate := httptest.NewRecorder() handler.ServeHTTP(recCreate, reqCreate) var proj projectworkflow.Project _ = json.Unmarshal(recCreate.Body.Bytes(), &proj) t.Cleanup(func() { cleanupProject(t, pool, proj.ID) }) creatorID := fetchAuthUserID(t, handler, creatorToken) reviewerID := fetchAuthUserID(t, handler, loginTestUser(t, handler, "lizhuguan", "123456")) approverID := fetchAuthUserID(t, handler, loginTestUser(t, handler, "wangzong", "123456")) reqDefault := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/workflow-plan/default?workflow_key=%s&version=1", proj.ID, wfKey), nil) withBearerAuth(reqDefault, creatorToken) recDefault := httptest.NewRecorder() handler.ServeHTTP(recDefault, reqDefault) var plan projectinstance.WorkflowPlan _ = json.Unmarshal(recDefault.Body.Bytes(), &plan) 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", proj.ID), bytes.NewReader(saveBody)) withBearerAuth(reqSave, creatorToken) recSave := httptest.NewRecorder() handler.ServeHTTP(recSave, reqSave) reqPreview := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/projects/%d/activation-preview", proj.ID), nil) withBearerAuth(reqPreview, creatorToken) recPreview := httptest.NewRecorder() handler.ServeHTTP(recPreview, reqPreview) 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", proj.ID), 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", proj.ID), 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()) } }