Files
weichenandCursor fad7fac044 fix(authz): close preference, session project, and recategorize IDOR gaps
Bind preference writes and session-scoped project reads to the authenticated principal, and require an owned session_id for recategorize so empty ids cannot touch all messages.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 10:42:58 +00:00

133 lines
4.4 KiB
Go

package server_test
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"chat-agent-system/internal/bizdev"
"chat-agent-system/internal/config"
"chat-agent-system/internal/server"
)
func TestServer_ChatRestoreAfterRestart(t *testing.T) {
cfg := &config.Config{
HTTPHost: "127.0.0.1",
HTTPPort: 8889,
DataDir: t.TempDir(),
AdminUsername: "admin",
AdminPassword: "admin-test-pass",
AgentBackend: "stub",
}
svc := bizdev.MustNewService(t, cfg)
srv := server.NewServer(cfg, svc)
handler := srv.Routes()
adminToken := loginTestAdmin(t, handler, cfg)
sessID := "sess_restore_test_001"
userID := int64(88)
// Turn 1: 用户发送第一条消息
chatReq1 := map[string]any{
"session_id": sessID,
"user_id": userID,
"project_name": "高频超声手术刀系统",
"device_type": "超声手术设备",
"message": "你好,我们正在规划立项一款高频超声手术刀系统,预算200万",
}
bodyBytes1, _ := json.Marshal(chatReq1)
req1 := httptest.NewRequest("POST", "/api/v1/chat", bytes.NewReader(bodyBytes1))
req1.Header.Set("Content-Type", "application/json")
rr1 := httptest.NewRecorder()
handler.ServeHTTP(rr1, req1)
if rr1.Code != http.StatusOK {
t.Fatalf("turn 1 failed with status %d: %s", rr1.Code, rr1.Body.String())
}
var resp1 map[string]any
if err := json.Unmarshal(rr1.Body.Bytes(), &resp1); err != nil {
t.Fatalf("unmarshal resp1 failed: %v", err)
}
reply1, _ := resp1["reply_text"].(string)
if reply1 == "" {
t.Fatalf("turn 1 reply text is empty")
}
t.Logf("✓ Turn 1 完成: %s", reply1)
// 验证消息已被持久化至 ContextManager
msgs, err := bizdev.Snapshot(svc).Ctx.GetChatHistory(context.Background(), sessID, 10, 0)
if err != nil || len(msgs) != 2 {
t.Fatalf("expected 2 messages in ContextManager, got %d (err: %v)", len(msgs), err)
}
// 模拟服务重启 / 内存淘汰 (G9 CleanStaleSessions 触发清空内存 map)
time.Sleep(5 * time.Millisecond)
cleaned := srv.CleanStaleSessions(1 * time.Millisecond)
if cleaned < 1 {
t.Fatalf("expected at least 1 session to be cleaned from memory, got %d", cleaned)
}
t.Logf("✓ 成功模拟会话从内存完全淘汰 (释放会话数=%d)", cleaned)
// Turn 2: 服务重启后,用户携相同 session_id 再次发起追问 (G3 自动恢复)
chatReq2 := map[string]any{
"session_id": sessID,
"user_id": userID,
"message": "关于刚才说的这个项目,国内型检和审评审批有哪些核心强标?",
}
bodyBytes2, _ := json.Marshal(chatReq2)
req2 := httptest.NewRequest("POST", "/api/v1/chat", bytes.NewReader(bodyBytes2))
req2.Header.Set("Content-Type", "application/json")
rr2 := httptest.NewRecorder()
handler.ServeHTTP(rr2, req2)
if rr2.Code != http.StatusOK {
t.Fatalf("turn 2 failed with status %d: %s", rr2.Code, rr2.Body.String())
}
var resp2 map[string]any
if err := json.Unmarshal(rr2.Body.Bytes(), &resp2); err != nil {
t.Fatalf("unmarshal resp2 failed: %v", err)
}
reply2, _ := resp2["reply_text"].(string)
if reply2 == "" {
t.Fatalf("turn 2 reply text is empty")
}
t.Logf("✓ Turn 2 完成: %s", reply2)
// 验证完整历史记录包含全部 4 条消息 (Turn 1 user+asst, Turn 2 user+asst)
allMsgs, err := bizdev.Snapshot(svc).Ctx.GetChatHistory(context.Background(), sessID, 10, 0)
if err != nil {
t.Fatalf("failed to query chat history: %v", err)
}
if len(allMsgs) != 4 {
t.Fatalf("expected 4 total messages after restore, got %d", len(allMsgs))
}
if allMsgs[0].Role != "user" || !strings.Contains(allMsgs[0].Content, "高频超声手术刀系统") {
t.Errorf("turn 1 user message mismatch: %+v", allMsgs[0])
}
if allMsgs[2].Role != "user" || !strings.Contains(allMsgs[2].Content, "核心强标") {
t.Errorf("turn 2 user message mismatch: %+v", allMsgs[2])
}
// 通过 /api/v1/projects 检查恢复后的上下文事实(须带认证)
reqCtx := httptest.NewRequest("GET", "/api/v1/projects?session_id="+sessID, nil)
withBearerAuth(reqCtx, adminToken)
rrCtx := httptest.NewRecorder()
handler.ServeHTTP(rrCtx, reqCtx)
if rrCtx.Code != http.StatusOK {
t.Fatalf("GET /api/v1/projects failed: %d %s", rrCtx.Code, rrCtx.Body.String())
}
var ctxMap map[string]any
_ = json.Unmarshal(rrCtx.Body.Bytes(), &ctxMap)
facts, _ := ctxMap["blackboard_facts"].(map[string]any)
if facts != nil {
t.Logf("✓ 恢复后的项目黑板事实: %v", facts)
}
}