Files
Cyrene_v2/internal/services/channelbridge/inbound_documents_inline_test.go
AskaEth 3be70f8232
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(channels): 适配器入站内联附件字节(telegram/kook/qq_obv11/weixin_ilink)
- 新增 internal/channel/mediafetch:来源白名单(deny-by-default,支持 *.suffix)
  + 逐跳重定向校验 + 边读边限 + 超时;只回来源主机,绝不回完整 URL
- 四渠道各自 media.go 接通入站内联:只从平台载荷给出的引用取回字节
  (telegram file_id→getFile→file_path 形态校验;kook CDN;qq 文件段 url;
   weixin cdnURL + AES-ECB 解密)
- 常量全部复用既有来源:documents.ExtAllowed / DefaultMaxBytes / MaxPerMessage
- channelbridge 诚实提示文案改为对「渠道只给远程引用」与「代取失败」两种成因都成立
- 18 个测试函数(含四渠道「正文里的 URL ⇒ 0 次命中」红线反证、端到端进模型可见视图)
- 变异验证:去掉尺寸上限 ⇒ 四渠道红(含修正后不再是假绿的 weixin 超量用例)
2026-10-04 22:13:17 +08:00

184 lines
7.0 KiB
Go

package channelbridge
// 本文件承载 **IM-1 × M1 的跨层端到端用例**:让**真的 Telegram 适配器**在入站时把
// 平台文件取回并内联,再把那条消息喂进**真的桥接入站流水线**(真 store + 真抽取器 +
// 真循环 + 假模型,夹具沿用 inbound_documents_test.go),断言模型可见视图里出现该附件
// 的清单行与正文 —— 即任务书 IM-1 §4-1 要求的「与 M1 既有集成用例同形状」。
//
// 另含本切片的红线:正文里点名的任意 URL **绝不**被取回(按路径分别断言命中次数)。
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/telegram"
)
// e2eAssetServer 是同时充当「Telegram Bot API」与「文件服务端」的假服务端:它按
// 路径分别计数,使「附件取回 1 次、正文里的 URL 0 次」可以分别断言。
type e2eAssetServer struct {
srv *httptest.Server
docHits atomic.Int32
bodyHits atomic.Int32
docBody string
}
func newE2EAssetServer(t *testing.T, docBody string) *e2eAssetServer {
t.Helper()
s := &e2eAssetServer{docBody: docBody}
s.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "/getFile"):
okResponseJSON(w, map[string]any{
"file_id": "doc-id", "file_path": "documents/report.txt", "file_size": len(docBody),
})
case strings.Contains(r.URL.Path, "/file/bot") && strings.HasSuffix(r.URL.Path, "documents/report.txt"):
s.docHits.Add(1)
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte(docBody))
case strings.Contains(r.URL.Path, "/file/bot"):
// 正文里点名的那个 URL 打到这里:它**必须** 0 次命中。
s.bodyHits.Add(1)
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte("绝不该被取回"))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(s.srv.Close)
return s
}
// okResponseJSON 写一个 Bot API 成功信封。
func okResponseJSON(w http.ResponseWriter, result any) {
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": result})
}
// e2eUpdate 构造一条主人(chat/from = 100)的文档消息,正文里**额外点名**一个 URL。
func e2eUpdate(t *testing.T, fileName, text string) []byte {
t.Helper()
raw, err := json.Marshal(map[string]any{
"update_id": 1,
"message": map[string]any{
"message_id": 42,
"date": 1700000000,
"chat": map[string]any{"id": 100, "type": "private"},
"from": map[string]any{"id": 100, "first_name": "主人"},
"text": text,
"document": map[string]any{
"file_id": "doc-id", "file_name": fileName, "file_size": 64, "mime_type": "text/plain",
},
},
})
if err != nil {
t.Fatalf("marshal update: %v", err)
}
return raw
}
// TestInboundAdapterInlineReachesModelView 是切片 IM-1 的主验收用例:真适配器取回
// 字节 → 桥接摄取 → 抽取 → **模型可见视图里出现清单行与正文**。
func TestInboundAdapterInlineReachesModelView(t *testing.T) {
t.Parallel()
env := newIngestEnv(t, documents.StoreConfig{}, true)
body := "第一行:IM 附件正文(IM-1 端到端)\n第二行:必须真的进上下文\n"
s := newE2EAssetServer(t, body)
// 真适配器:Token/BaseURL/Client 都指向假服务端,日志丢弃。
ad := telegram.New(
telegram.Config{BotToken: "123456:TESTTOKEN"},
telegram.WithBaseURL(s.srv.URL),
telegram.WithHTTPClient(s.srv.Client()),
telegram.WithLogger(slog.New(slog.NewTextHandler(discardWriter{}, nil))),
)
var delivered []adapter.UnifiedMessage
ad.SetMessageHandler(func(_ context.Context, msg adapter.UnifiedMessage) {
delivered = append(delivered, msg)
})
// 正文里点名一个「附件地址」形态的 URL(假服务端确实能应答它)。
bodyURL := s.srv.URL + "/file/bot123456:TESTTOKEN/documents/body_only.txt"
if _, err := ad.ProcessUpdate(context.Background(), e2eUpdate(t, "report.txt", "读一下 "+bodyURL)); err != nil {
t.Fatalf("ProcessUpdate: %v", err)
}
if len(delivered) != 1 {
t.Fatalf("投递条数 = %d, want 1", len(delivered))
}
if len(delivered[0].Media) != 1 || string(delivered[0].Media[0].Data) != body {
t.Fatalf("适配器没有把附件字节内联进来:%+v", delivered[0].Media)
}
env.bridge.process(context.Background(), delivered[0])
names := docDirNames(t, env.uploads)
if len(names) != 1 {
t.Fatalf("落盘文件数 = %v, want 1(内联后的附件必须落库)", names)
}
visible := env.model.visibleText()
if !strings.Contains(visible, "[附件 "+names[0]+" · ") {
t.Fatalf("模型可见视图里没有附件清单行:%q", visible)
}
if !strings.Contains(visible, "第二行:必须真的进上下文") {
t.Fatalf("模型可见视图里没有附件正文:%q", visible)
}
if !strings.Contains(visible, "读一下 ") {
t.Fatalf("模型可见视图里丢了用户文本:%q", visible)
}
// 红线:附件本身被取回 1 次,正文里点名的那个 URL 一次都没有。
if got := s.docHits.Load(); got != 1 {
t.Fatalf("附件取回次数 = %d, want 1", got)
}
if got := s.bodyHits.Load(); got != 0 {
t.Fatalf("正文里的 URL 被取回 %d 次,want 0", got)
}
}
// TestInboundAdapterUnsupportedFileStaysHonest 钉住「适配器不下载非白名单格式」与
// 「桥接照既有口径给模型诚实提示」在同一条链路上一致。
func TestInboundAdapterUnsupportedFileStaysHonest(t *testing.T) {
t.Parallel()
env := newIngestEnv(t, documents.StoreConfig{}, true)
s := newE2EAssetServer(t, "不该被取回的内容\n")
ad := telegram.New(
telegram.Config{BotToken: "123456:TESTTOKEN"},
telegram.WithBaseURL(s.srv.URL),
telegram.WithHTTPClient(s.srv.Client()),
telegram.WithLogger(slog.New(slog.NewTextHandler(discardWriter{}, nil))),
)
var delivered []adapter.UnifiedMessage
ad.SetMessageHandler(func(_ context.Context, msg adapter.UnifiedMessage) {
delivered = append(delivered, msg)
})
if _, err := ad.ProcessUpdate(context.Background(), e2eUpdate(t, "payload.exe", "跑一下这个")); err != nil {
t.Fatalf("ProcessUpdate: %v", err)
}
env.bridge.process(context.Background(), delivered[0])
if names := docDirNames(t, env.uploads); len(names) != 0 {
t.Fatalf("非白名单格式被落库了:%v", names)
}
visible := env.model.visibleText()
if !strings.Contains(visible, "payload.exe") || !strings.Contains(visible, "无法读取") {
t.Fatalf("模型可见视图里没有诚实的拒绝提示:%q", visible)
}
if got := s.docHits.Load() + s.bodyHits.Load(); got != 0 {
t.Fatalf("非白名单格式发起了 %d 次资产请求,want 0", got)
}
}
// discardWriter 是丢弃输出的 io.Writer(用例不关心适配器日志内容)。
type discardWriter struct{}
func (discardWriter) Write(p []byte) (int, error) { return len(p), nil }