Files
weichen 3e01f18756 merge: integrate final-artifact-vault into local_dev (authorized exception)
User authorized lifting CURRENT FAV whole-branch merge protection on
2026-09-23. Keep FAV_DEGRADED_READ/FAV_PUBLISHER_ENABLED default false.
Drop duplicate goose 00034 (local_dev already has 00036). Preserve
agentx workspace admin routes and pin-export wiring.
2026-09-23 13:57:08 +00:00

224 lines
8.4 KiB
Go

package server_test
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"chat-agent-system/internal/bizdev"
"chat-agent-system/internal/config"
"chat-agent-system/internal/server"
)
func TestServer_ReplyExportAPI(t *testing.T) {
cfg := &config.Config{
Env: "dev",
HTTPHost: "127.0.0.1",
HTTPPort: 8893,
DataDir: t.TempDir(),
}
svc := bizdev.MustNewService(t, cfg)
srv := server.NewServer(cfg, svc)
handler := srv.Routes()
ctx := t.Context()
ctxMgr := bizdev.Snapshot(svc).Ctx
sessID := "sess_export_api_test"
token := loginTestAdmin(t, handler, cfg)
if _, err := ctxMgr.EnsureChatSession(ctx, sessID, 1, 0, "导出测试会话"); err != nil {
t.Fatalf("EnsureChatSession failed: %v", err)
}
// 1. 注入一条完整 assistant 消息
fullContent := "# 临床风险评估报告\n\n根据系统审查,该产品存在以下风险点:\n1. 电气隔离距离不足\n2. 软件未定义看门狗"
asstID, err := ctxMgr.RecordUserMessageWithID(ctx, sessID, 0, "assistant", fullContent, "", "", nil)
if err != nil {
t.Fatalf("RecordUserMessageWithID failed: %v", err)
}
// 2. 注入一条纯卡片依赖引导消息
cardOnlyContent := "请确认以下操作卡片:"
cardMeta := map[string]any{
"interactive_card": map[string]any{
"card_id": "c1",
"title": "驳回门禁",
},
}
cardOnlyID, err := ctxMgr.RecordUserMessageWithID(ctx, sessID, 0, "assistant", cardOnlyContent, "", "", cardMeta)
if err != nil {
t.Fatalf("RecordUserMessageWithID cardOnly failed: %v", err)
}
// -------------------------------------------------------------
// 测试 A: 获取 full 消息能力
// -------------------------------------------------------------
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/chat/messages/%d/export-capability", asstID), nil)
withBearerAuth(req, token)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
}
var capRes map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &capRes); err != nil {
t.Fatalf("json unmarshal failed: %v", err)
}
if capRes["policy"] != "full" {
t.Errorf("expected policy full, got %v", capRes["policy"])
}
formats, ok := capRes["formats"].([]any)
if !ok || len(formats) == 0 {
t.Errorf("expected non-empty formats list")
}
// -------------------------------------------------------------
// 测试 B: 获取 cardOnly 消息能力 (policy = none, reason = CARD_ONLY)
// -------------------------------------------------------------
reqCard := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/chat/messages/%d/export-capability", cardOnlyID), nil)
withBearerAuth(reqCard, token)
recCard := httptest.NewRecorder()
handler.ServeHTTP(recCard, reqCard)
if recCard.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", recCard.Code, recCard.Body.String())
}
var cardCapRes map[string]any
_ = json.Unmarshal(recCard.Body.Bytes(), &cardCapRes)
if cardCapRes["policy"] != "none" {
t.Errorf("expected cardOnly policy none, got %v", cardCapRes["policy"])
}
if cardCapRes["reason_code"] != "CARD_ONLY" {
t.Errorf("expected CARD_ONLY, got %v", cardCapRes["reason_code"])
}
// -------------------------------------------------------------
// 测试 C: 获取 full 消息正文快照
// -------------------------------------------------------------
reqSrc := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/chat/messages/%d/export-source", asstID), nil)
withBearerAuth(reqSrc, token)
recSrc := httptest.NewRecorder()
handler.ServeHTTP(recSrc, reqSrc)
if recSrc.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", recSrc.Code, recSrc.Body.String())
}
var srcRes map[string]any
if err := json.Unmarshal(recSrc.Body.Bytes(), &srcRes); err != nil {
t.Fatalf("json unmarshal failed: %v", err)
}
if srcRes["title"] != "临床风险评估报告" {
t.Errorf("expected title '临床风险评估报告', got %v", srcRes["title"])
}
if srcRes["markdown"] != fullContent {
t.Errorf("expected markdown match, got %v", srcRes["markdown"])
}
if recSrc.Header().Get("Cache-Control") != "private, no-store" {
t.Errorf("expected Cache-Control: private, no-store, got %v", recSrc.Header().Get("Cache-Control"))
}
// -------------------------------------------------------------
// 测试 D: cardOnly 请求正文快照应返回 400 MESSAGE_NOT_EXPORTABLE
// -------------------------------------------------------------
reqCardSrc := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/chat/messages/%d/export-source", cardOnlyID), nil)
withBearerAuth(reqCardSrc, token)
recCardSrc := httptest.NewRecorder()
handler.ServeHTTP(recCardSrc, reqCardSrc)
if recCardSrc.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", recCardSrc.Code)
}
// -------------------------------------------------------------
// 测试 E: 不存在的消息 ID 返回 404
// -------------------------------------------------------------
reqNotFound := httptest.NewRequest(http.MethodGet, "/api/v1/chat/messages/999999/export-source", nil)
withBearerAuth(reqNotFound, token)
recNotFound := httptest.NewRecorder()
handler.ServeHTTP(recNotFound, reqNotFound)
if recNotFound.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", recNotFound.Code)
}
// -------------------------------------------------------------
// 测试 F: 冲突的 content_revision 返回 409
// -------------------------------------------------------------
reqConflict := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/chat/messages/%d/export-source?content_revision=999", asstID), nil)
withBearerAuth(reqConflict, token)
recConflict := httptest.NewRecorder()
handler.ServeHTTP(recConflict, reqConflict)
if recConflict.Code != http.StatusConflict {
t.Fatalf("expected 409, got %d", recConflict.Code)
}
// 测试 G: 未登录返回 401(严格模式,禁用测试回退)
strictCfg := &config.Config{DataDir: t.TempDir(), AgentBackend: "stub", Env: "test", AllowInMemory: true}
strictSvc := bizdev.MustNewService(t, strictCfg)
strictSrv := server.NewServer(strictCfg, strictSvc)
strictCfg.AllowInMemory = false
strictHandler := strictSrv.Routes()
reqUnauth := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/chat/messages/%d/export-source", asstID), nil)
recUnauth := httptest.NewRecorder()
strictHandler.ServeHTTP(recUnauth, reqUnauth)
if recUnauth.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 without auth, got %d", recUnauth.Code)
}
// 测试 H: 其他用户无权导出 (IDOR)
reqIDOR := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/chat/messages/%d/export-source", asstID), nil)
withUserIDAuth(reqIDOR, loginTestUser(t, handler, "zhanggong", "123456"))
recIDOR := httptest.NewRecorder()
handler.ServeHTTP(recIDOR, reqIDOR)
if recIDOR.Code != http.StatusForbidden {
t.Fatalf("expected 403 for cross-user export, got %d: %s", recIDOR.Code, recIDOR.Body.String())
}
}
func TestServer_PinExportCopyOnlyAfterCompleted(t *testing.T) {
cfg := &config.Config{
Env: "dev",
HTTPHost: "127.0.0.1",
HTTPPort: 8894,
DataDir: t.TempDir(),
}
svc := bizdev.MustNewService(t, cfg)
srv := server.NewServer(cfg, svc)
handler := srv.Routes()
ctx := t.Context()
cm := bizdev.Snapshot(svc).Ctx
sessID := "sess_pin_export"
token := loginTestAdmin(t, handler, cfg)
if _, err := cm.EnsureChatSession(ctx, sessID, 1, 7, "pin 测试"); err != nil {
t.Fatal(err)
}
if _, err := cm.AcquireChatTurn(ctx, sessID, "turn-pin", "hash-pin", "req-pin", 1, 7); err != nil {
t.Fatal(err)
}
msgID, err := cm.RecordUserMessageWithID(ctx, sessID, 1, "assistant", "好的", "", "", nil)
if err != nil {
t.Fatal(err)
}
reqEarly := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/chat/messages/%d/pin-export", msgID), nil)
withBearerAuth(reqEarly, token)
recEarly := httptest.NewRecorder()
handler.ServeHTTP(recEarly, reqEarly)
if recEarly.Code != http.StatusConflict {
t.Fatalf("pin before completed want 409, got %d: %s", recEarly.Code, recEarly.Body.String())
}
if err := cm.CompleteChatTurn(ctx, sessID, "turn-pin", msgID); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/chat/messages/%d/pin-export", msgID), nil)
withBearerAuth(req, token)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("pin after completed want 200, got %d: %s", rec.Code, rec.Body.String())
}
}