封堵鉴权旁路与导出 IDOR,DocGov 改服务端身份;对话层 PendingAction 接真实 docgov/taskrun;修复流式租约假死与会话级任务流;加固配置/doc_id/NULL;补齐 slog 与测试;前端 ErrorBoundary 与受控文档重试。 Co-authored-by: Cursor <cursoragent@cursor.com>
370 lines
11 KiB
Go
370 lines
11 KiB
Go
package server
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"chat-agent-system/internal/bizdev"
|
|
"chat-agent-system/internal/chat"
|
|
"chat-agent-system/internal/config"
|
|
"chat-agent-system/internal/stream"
|
|
)
|
|
|
|
func newTestServer() *Server {
|
|
cfg := &config.Config{
|
|
Env: "test",
|
|
AgentBackend: "stub",
|
|
DataDir: "./data/test",
|
|
KbDbPort: 5432,
|
|
AdminUsername: "admin",
|
|
AdminPassword: "admin-test-pass",
|
|
}
|
|
svc := bizdev.NewService(cfg)
|
|
return NewServer(cfg, svc)
|
|
}
|
|
|
|
func TestServer_Health(t *testing.T) {
|
|
srv := newTestServer()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
srv.Routes().ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d", w.Code)
|
|
}
|
|
|
|
var resp HealthResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode response failed: %v", err)
|
|
}
|
|
|
|
if resp.Status != "ok" || resp.Env != "test" {
|
|
t.Errorf("unexpected health response: %+v", resp)
|
|
}
|
|
}
|
|
|
|
func TestServer_ChatBlocking(t *testing.T) {
|
|
srv := newTestServer()
|
|
|
|
chatReq := ChatRequest{
|
|
SessionID: "test_sess_01",
|
|
Message: "查一下当前进展",
|
|
ProjectName: "内窥镜研发项目",
|
|
CustomerName: "水木东方",
|
|
}
|
|
body, _ := json.Marshal(chatReq)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/chat", bytes.NewReader(body))
|
|
w := httptest.NewRecorder()
|
|
|
|
srv.Routes().ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp chat.ChatResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode chat response failed: %v", err)
|
|
}
|
|
|
|
if resp.ReplyText == "" {
|
|
t.Errorf("expected non-empty reply text")
|
|
}
|
|
}
|
|
|
|
func TestServer_ChatSSEStream(t *testing.T) {
|
|
srv := newTestServer()
|
|
|
|
chatReq := StreamChatRequest{
|
|
SessionID: "test_sess_sse",
|
|
Message: "查一下当前进展",
|
|
IncludeDetails: false,
|
|
ProjectName: "内窥镜研发项目",
|
|
CustomerName: "水木东方",
|
|
}
|
|
body, _ := json.Marshal(chatReq)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/chat/stream", bytes.NewReader(body))
|
|
w := httptest.NewRecorder()
|
|
|
|
srv.Routes().ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d", w.Code)
|
|
}
|
|
|
|
contentType := w.Header().Get("Content-Type")
|
|
if !strings.Contains(contentType, "text/event-stream") {
|
|
t.Fatalf("expected Content-Type text/event-stream, got %s", contentType)
|
|
}
|
|
|
|
// 解析 SSE 事件流
|
|
scanner := bufio.NewScanner(w.Body)
|
|
var currentEvent string
|
|
var eventsReceived []string
|
|
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if strings.HasPrefix(line, "event: ") {
|
|
currentEvent = strings.TrimPrefix(line, "event: ")
|
|
} else if strings.HasPrefix(line, "data: ") {
|
|
dataStr := strings.TrimPrefix(line, "data: ")
|
|
var ev stream.StreamEvent
|
|
if err := json.Unmarshal([]byte(dataStr), &ev); err == nil {
|
|
eventsReceived = append(eventsReceived, currentEvent)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(eventsReceived) == 0 {
|
|
t.Fatalf("expected to receive SSE events, got 0")
|
|
}
|
|
|
|
hasPhase := false
|
|
hasStatus := false
|
|
hasDone := false
|
|
|
|
for _, et := range eventsReceived {
|
|
switch et {
|
|
case "phase":
|
|
hasPhase = true
|
|
case "status":
|
|
hasStatus = true
|
|
case "done":
|
|
hasDone = true
|
|
}
|
|
}
|
|
|
|
if !hasPhase {
|
|
t.Errorf("expected phase event in SSE stream")
|
|
}
|
|
if !hasStatus {
|
|
t.Errorf("expected status event in SSE stream")
|
|
}
|
|
if !hasDone {
|
|
t.Errorf("expected done event in SSE stream")
|
|
}
|
|
}
|
|
|
|
func TestServer_ContextAndDocGovAPIs(t *testing.T) {
|
|
srv := newTestServer()
|
|
token := loginTestAdmin(t, srv)
|
|
actorID := int64(1)
|
|
|
|
// 1. 测试更新并获取用户偏好
|
|
prefReq := UpdateUserPreferenceRequest{
|
|
UserID: 101,
|
|
CommunicationStyle: "concise_structured",
|
|
FocusAreas: []string{"cost_budget", "reg_compliance"},
|
|
OutputFormatPref: "standard_report",
|
|
CustomInstructions: "结论先行,重点突出NMPA二类申报风险",
|
|
}
|
|
prefBytes, _ := json.Marshal(prefReq)
|
|
req := httptest.NewRequest(http.MethodPut, "/api/v1/context/user/preference", bytes.NewReader(prefBytes))
|
|
w := httptest.NewRecorder()
|
|
srv.Routes().ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 from update user preference, got %d", w.Code)
|
|
}
|
|
|
|
// 2. 测试获取单用户上下文
|
|
req = httptest.NewRequest(http.MethodGet, "/api/v1/context/user?user_id=101&project_id=1", nil)
|
|
w = httptest.NewRecorder()
|
|
srv.Routes().ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 from get user context, got %d", w.Code)
|
|
}
|
|
|
|
// 3. 测试获取项目事实黑板上下文
|
|
req = httptest.NewRequest(http.MethodGet, "/api/v1/context/project?project_id=1", nil)
|
|
w = httptest.NewRecorder()
|
|
srv.Routes().ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 from get project context, got %d", w.Code)
|
|
}
|
|
|
|
// 4. 测试任务运行与文档生成
|
|
taskReq := TaskRunRequestDTO{
|
|
TaskKey: "feasibility_reg",
|
|
ProjectID: 1,
|
|
ProjectName: "超声骨刀系统",
|
|
CustomerName: "北京水木东方",
|
|
DeviceType: "超声手术设备",
|
|
UserID: actorID,
|
|
AuthorID: actorID,
|
|
AuthorName: "张工",
|
|
ReviewerID: actorID,
|
|
ReviewerName: "李主管",
|
|
ApproverID: actorID,
|
|
ApproverName: "王总监",
|
|
}
|
|
taskBytes, _ := json.Marshal(taskReq)
|
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/tasks/run", bytes.NewReader(taskBytes))
|
|
w = httptest.NewRecorder()
|
|
srv.Routes().ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 from tasks/run, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var taskRes bizdev.TaskRunResult
|
|
if err := json.NewDecoder(w.Body).Decode(&taskRes); err != nil {
|
|
t.Fatalf("decode task run result: %v", err)
|
|
}
|
|
docID := "doc_proj1_feasibility_reg"
|
|
if taskRes.DocRecord != nil && taskRes.DocRecord.DocID != "" {
|
|
docID = taskRes.DocRecord.DocID
|
|
}
|
|
|
|
// 5. 查询生成的受控文档
|
|
req = httptest.NewRequest(http.MethodGet, "/api/v1/docgov/document?doc_id="+docID, nil)
|
|
w = httptest.NewRecorder()
|
|
srv.Routes().ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 from get docgov document, got %d", w.Code)
|
|
}
|
|
|
|
// 6. 查阅并认领文档
|
|
claimReq := DocGovClaimRequest{
|
|
DocID: docID,
|
|
UserID: 101,
|
|
UserName: "张工",
|
|
UserRole: "author",
|
|
}
|
|
claimBytes, _ := json.Marshal(claimReq)
|
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/docgov/claim", bytes.NewReader(claimBytes))
|
|
withBearerAuth(req, token)
|
|
w = httptest.NewRecorder()
|
|
srv.Routes().ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 from claim doc, got %d", w.Code)
|
|
}
|
|
|
|
// 7. 已认领文档再次运行被拦截,生成变更提案
|
|
taskReq2 := taskReq
|
|
taskReq2.TargetMarket = "欧盟CE"
|
|
taskBytes2, _ := json.Marshal(taskReq2)
|
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/tasks/run", bytes.NewReader(taskBytes2))
|
|
w = httptest.NewRecorder()
|
|
srv.Routes().ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 with proposal from tasks/run, got %d", w.Code)
|
|
}
|
|
var interceptedRes bizdev.TaskRunResult
|
|
_ = json.NewDecoder(w.Body).Decode(&interceptedRes)
|
|
if !interceptedRes.RequiresAuthorApproval || interceptedRes.PendingProposal == nil {
|
|
t.Fatalf("expected RequiresAuthorApproval=true and non-nil PendingProposal")
|
|
}
|
|
|
|
// 8. 编制人拍板同意变更提案
|
|
decideReq := DecideProposalRequest{
|
|
ProposalID: interceptedRes.PendingProposal.ProposalID,
|
|
AuthorID: 101,
|
|
Approve: true,
|
|
Reason: "经评估符合出海战略,同意增补CE条款",
|
|
}
|
|
decideBytes, _ := json.Marshal(decideReq)
|
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/docgov/proposal/decide", bytes.NewReader(decideBytes))
|
|
withBearerAuth(req, token)
|
|
w = httptest.NewRecorder()
|
|
srv.Routes().ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 from proposal/decide, got %d", w.Code)
|
|
}
|
|
|
|
// 9. 添加批注
|
|
commReq := AddCommentRequestDTO{
|
|
DocID: docID,
|
|
UserID: 102,
|
|
UserName: "李主管",
|
|
UserRole: "reviewer",
|
|
TargetSection: "## 法规结论",
|
|
CommentType: "suggestion",
|
|
Content: "建议补充针对大血管闭合的YY/T 0644条款",
|
|
}
|
|
commBytes, _ := json.Marshal(commReq)
|
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/docgov/comments", bytes.NewReader(commBytes))
|
|
w = httptest.NewRecorder()
|
|
srv.Routes().ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 from add comment, got %d", w.Code)
|
|
}
|
|
|
|
// 10. 送审
|
|
submitReq := DocGovSubmitReviewRequest{
|
|
DocID: docID,
|
|
UserID: 101,
|
|
UserName: "张工",
|
|
}
|
|
submitBytes, _ := json.Marshal(submitReq)
|
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/docgov/submit-review", bytes.NewReader(submitBytes))
|
|
withBearerAuth(req, token)
|
|
w = httptest.NewRecorder()
|
|
srv.Routes().ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 from submit review, got %d", w.Code)
|
|
}
|
|
|
|
// 11. 审核驳回校验 (空原因应被拦截)
|
|
rejReq := DocGovDecisionRequest{
|
|
DocID: docID,
|
|
UserID: 102,
|
|
UserName: "李主管",
|
|
Action: "reject",
|
|
Reason: "",
|
|
}
|
|
rejBytes, _ := json.Marshal(rejReq)
|
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/docgov/review-decision", bytes.NewReader(rejBytes))
|
|
withBearerAuth(req, token)
|
|
w = httptest.NewRecorder()
|
|
srv.Routes().ServeHTTP(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for empty reject reason, got %d", w.Code)
|
|
}
|
|
|
|
// 12. 审核通过
|
|
appReq := DocGovDecisionRequest{
|
|
DocID: docID,
|
|
UserID: 102,
|
|
UserName: "李主管",
|
|
Action: "approve",
|
|
Comment: "初审符合注册法规要求,同意转交批准",
|
|
}
|
|
appBytes, _ := json.Marshal(appReq)
|
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/docgov/review-decision", bytes.NewReader(appBytes))
|
|
withBearerAuth(req, token)
|
|
w = httptest.NewRecorder()
|
|
srv.Routes().ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 for review approve, got %d", w.Code)
|
|
}
|
|
|
|
// 13. 批准人批准
|
|
apprReq := DocGovDecisionRequest{
|
|
DocID: docID,
|
|
UserID: 103,
|
|
UserName: "王总监",
|
|
Action: "approve",
|
|
Comment: "准予受控生效",
|
|
}
|
|
apprBytes, _ := json.Marshal(apprReq)
|
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/docgov/approver-decision", bytes.NewReader(apprBytes))
|
|
withBearerAuth(req, token)
|
|
w = httptest.NewRecorder()
|
|
srv.Routes().ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 for approver approve, got %d", w.Code)
|
|
}
|
|
|
|
// 14. 查询审计日志
|
|
req = httptest.NewRequest(http.MethodGet, "/api/v1/docgov/audit-logs?doc_id="+docID, nil)
|
|
w = httptest.NewRecorder()
|
|
srv.Routes().ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 from audit logs, got %d", w.Code)
|
|
}
|
|
}
|