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.
224 lines
8.4 KiB
Go
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())
|
|
}
|
|
}
|