Files
Cyrene_v2/plugins/channels/telegram/media_test.go
T
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

317 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package telegram
// 本文件锁定 Telegram 的**入站附件字节内联**(切片 IM-1):
//
// - 正向:平台给 file_id ⇒ getFile 换路径 ⇒ 下载字节内联(假 HTTP 服务端,不碰真平台);
// - 超限:声明超限不发请求;未声明而服务端超量 ⇒ 边读边限、放弃内联、日志与提示都不静默;
// - 非白名单格式 ⇒ **不发起下载**(连 getFile 都不调,断言 0 次命中);
// - 超时/失败 ⇒ 诚实放弃(消息照收);
// - **红线**:正文里的任意 URL 绝不被取回(断言服务端 0 次命中)。
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
)
// tgAssetServer 是假的 Bot API + 文件服务端:它区分 getFile 调用与真正的文件下载,
// 使「0 次命中」可以精确断言在**文件下载**这件事上。
type tgAssetServer struct {
srv *httptest.Server
getFile atomic.Int32
fileHits atomic.Int32
allHits atomic.Int32
fileBody []byte
fileCode int
fileDelay bool
}
func newTGAssetServer(t *testing.T) *tgAssetServer {
t.Helper()
s := &tgAssetServer{fileBody: []byte("附件的正文\n")}
s.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s.allHits.Add(1)
switch {
case strings.HasSuffix(r.URL.Path, "/getFile"):
s.getFile.Add(1)
okResponse(w, map[string]any{"file_id": "doc-id", "file_path": "documents/file_1.txt"})
case strings.Contains(r.URL.Path, "/file/bot"):
s.fileHits.Add(1)
if s.fileCode != 0 {
w.WriteHeader(s.fileCode)
return
}
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write(s.fileBody)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(s.srv.Close)
return s
}
// captureHandler 捕获投递给上层的最后一条消息。
type captureHandler struct {
mu sync.Mutex
last adapter.UnifiedMessage
got int
}
func (h *captureHandler) fn(_ context.Context, msg adapter.UnifiedMessage) {
h.mu.Lock()
h.last = msg
h.got++
h.mu.Unlock()
}
func (h *captureHandler) message(t *testing.T) adapter.UnifiedMessage {
t.Helper()
h.mu.Lock()
defer h.mu.Unlock()
if h.got == 0 {
t.Fatal("附件消息没有被投递给上层")
}
return h.last
}
// newInlineAdapter 装配一个接到假服务端的适配器 + 捕获器 + 日志缓冲。
func newInlineAdapter(t *testing.T, s *tgAssetServer) (*Adapter, *captureHandler, *bytes.Buffer) {
t.Helper()
logs := &bytes.Buffer{}
a := New(
Config{BotToken: "123456:TESTTOKEN"},
WithBaseURL(s.srv.URL),
WithHTTPClient(s.srv.Client()),
WithLogger(logger.WithSource(slog.New(slog.NewTextHandler(logs, nil)), "plugins/channels/telegram")),
)
h := &captureHandler{}
a.SetMessageHandler(h.fn)
return a, h, logs
}
// documentUpdate 构造一条 Telegram 文档消息的原始 update。
func documentUpdate(name string, size int64, text string) []byte {
body := map[string]any{
"update_id": 1,
"message": map[string]any{
"message_id": 42,
"date": 1700000000,
"chat": map[string]any{"id": 7, "type": "private"},
"from": map[string]any{"id": 9, "first_name": "主人"},
"text": text,
"document": map[string]any{
"file_id": "doc-id", "file_name": name, "file_size": size, "mime_type": "text/plain",
},
},
}
raw, err := json.Marshal(body)
if err != nil {
panic(fmt.Sprintf("marshal update: %v", err))
}
return raw
}
// TestInboundDocumentIsInlined 是正向用例:file_id ⇒ getFile ⇒ 下载 ⇒ Data 被填、
// 且消息照常投递(Data 之外的字段一字不动)。
func TestInboundDocumentIsInlined(t *testing.T) {
t.Parallel()
s := newTGAssetServer(t)
a, h, _ := newInlineAdapter(t, s)
if _, err := a.ProcessUpdate(context.Background(), documentUpdate("report.txt", 12, "看看这个")); err != nil {
t.Fatalf("ProcessUpdate: %v", err)
}
msg := h.message(t)
if len(msg.Media) != 1 || string(msg.Media[0].Data) != "附件的正文\n" {
t.Fatalf("Media.Data = %q, want 下载到的字节", msg.Media[0].Data)
}
if msg.Media[0].Name != "report.txt" || msg.Media[0].URL != "doc-id" {
t.Fatalf("内联改动了媒体元数据:%+v", msg.Media[0])
}
if s.getFile.Load() != 1 || s.fileHits.Load() != 1 {
t.Fatalf("getFile=%d fileHits=%d, want 1/1", s.getFile.Load(), s.fileHits.Load())
}
}
// TestInboundDeclaredOversizeIsNotDownloaded 钉住「平台声明超限 ⇒ 连请求都不发」。
func TestInboundDeclaredOversizeIsNotDownloaded(t *testing.T) {
t.Parallel()
s := newTGAssetServer(t)
a, h, logs := newInlineAdapter(t, s)
_, err := a.ProcessUpdate(context.Background(), documentUpdate("big.txt", documents.DefaultMaxBytes+1, "超大文件"))
if err != nil {
t.Fatalf("ProcessUpdate: %v", err)
}
msg := h.message(t)
if len(msg.Media[0].Data) != 0 {
t.Fatalf("超限附件被内联了(%d 字节)", len(msg.Media[0].Data))
}
if s.getFile.Load() != 0 || s.allHits.Load() != 0 {
t.Fatalf("声明超限仍发起了请求:getFile=%d all=%d", s.getFile.Load(), s.allHits.Load())
}
if !strings.Contains(logs.String(), "declared size over limit") {
t.Fatalf("超限没有留下带来源的日志:%q", logs.String())
}
}
// TestInboundOversizeBodyIsCutWhileReading 钉住「边读边限」:平台声明的体积不可信
// (这里声明为 1 字节),服务端实际吐出超限字节时也要放弃。
func TestInboundOversizeBodyIsCutWhileReading(t *testing.T) {
t.Parallel()
s := newTGAssetServer(t)
s.fileBody = bytes.Repeat([]byte("x"), documents.DefaultMaxBytes+64)
a, h, logs := newInlineAdapter(t, s)
if _, err := a.ProcessUpdate(context.Background(), documentUpdate("big.txt", 1, "超量")); err != nil {
t.Fatalf("ProcessUpdate: %v", err)
}
msg := h.message(t)
if len(msg.Media[0].Data) != 0 {
t.Fatalf("超量响应被内联了(%d 字节)", len(msg.Media[0].Data))
}
if s.fileHits.Load() != 1 {
t.Fatalf("fileHits = %d, want 1(这次请求本来就该发)", s.fileHits.Load())
}
if !strings.Contains(logs.String(), "inline failed") || !strings.Contains(logs.String(), "体积上限") {
t.Fatalf("超量没有留下带来源的失败日志:%q", logs.String())
}
}
// TestInboundUnsupportedExtensionIsNotDownloaded 钉住预筛:非白名单格式**不发起下载**
// (连 getFile 都不调),消息照收。
func TestInboundUnsupportedExtensionIsNotDownloaded(t *testing.T) {
t.Parallel()
s := newTGAssetServer(t)
a, h, _ := newInlineAdapter(t, s)
if _, err := a.ProcessUpdate(context.Background(), documentUpdate("payload.exe", 1024, "跑一下")); err != nil {
t.Fatalf("ProcessUpdate: %v", err)
}
msg := h.message(t)
if len(msg.Media[0].Data) != 0 {
t.Fatalf("非白名单格式被内联了")
}
if s.allHits.Load() != 0 {
t.Fatalf("非白名单格式发起了 %d 次请求,want 0", s.allHits.Load())
}
}
// TestInboundFetchFailureIsHonest 钉住失败路径:服务端 5xx / 连接失败 ⇒ 放弃内联、
// 消息照收、日志带来源(且不含 URL 原串与 token)。
func TestInboundFetchFailureIsHonest(t *testing.T) {
t.Parallel()
s := newTGAssetServer(t)
s.fileCode = http.StatusInternalServerError
a, h, logs := newInlineAdapter(t, s)
if _, err := a.ProcessUpdate(context.Background(), documentUpdate("report.txt", 12, "读一下")); err != nil {
t.Fatalf("ProcessUpdate: %v", err)
}
msg := h.message(t)
if len(msg.Media[0].Data) != 0 {
t.Fatalf("失败时仍写入了字节")
}
if !strings.Contains(msg.Content, "读一下") {
t.Fatalf("失败路径改动了正文:%q", msg.Content)
}
log := logs.String()
if !strings.Contains(log, "inline failed") {
t.Fatalf("失败没有留下带来源的日志:%q", log)
}
if strings.Contains(log, "TESTTOKEN") || strings.Contains(log, "TESTTOKEN") {
t.Fatalf("日志泄露了 token:%q", log)
}
}
// TestInboundFetchHonorsContextCancel 钉住超时接线:父 ctx 取消时下载立刻放弃
// (不必等 30s 的具名超时),消息照收。
func TestInboundFetchHonorsContextCancel(t *testing.T) {
t.Parallel()
s := newTGAssetServer(t)
a, h, logs := newInlineAdapter(t, s)
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := a.ProcessUpdate(ctx, documentUpdate("report.txt", 12, "读一下")); err != nil {
t.Fatalf("ProcessUpdate: %v", err)
}
msg := h.message(t)
if len(msg.Media[0].Data) != 0 {
t.Fatalf("ctx 已取消却仍内联了字节")
}
if !strings.Contains(logs.String(), "inline failed") {
t.Fatalf("ctx 取消没有留下带来源的日志:%q", logs.String())
}
}
// TestInboundBodyURLIsNeverFetched 是本切片的红线用例:正文里带任意 URL 的消息
// **绝不**对那个 URL 发起请求(断言服务端 0 次命中)。
func TestInboundBodyURLIsNeverFetched(t *testing.T) {
t.Parallel()
s := newTGAssetServer(t)
a, _, _ := newInlineAdapter(t, s)
// 正文直接点名一个「附件地址」形态的 URL(服务端确实能应答它)。
body := "帮我读一下 " + s.srv.URL + "/file/bot123456:TESTTOKEN/documents/file_1.txt 这份文件"
if _, err := a.ProcessUpdate(context.Background(), bodyOnlyUpdate(body)); err != nil {
t.Fatalf("ProcessUpdate: %v", err)
}
if s.allHits.Load() != 0 {
t.Fatalf("正文里的 URL 被请求了 %d 次,want 0", s.allHits.Load())
}
}
// bodyOnlyUpdate 构造一条**只有正文、没有任何媒体**的消息。
func bodyOnlyUpdate(text string) []byte {
body := map[string]any{
"update_id": 2,
"message": map[string]any{
"message_id": 43,
"date": 1700000001,
"chat": map[string]any{"id": 7, "type": "private"},
"from": map[string]any{"id": 9, "first_name": "主人"},
"text": text,
},
}
raw, err := json.Marshal(body)
if err != nil {
panic(fmt.Sprintf("marshal update: %v", err))
}
return raw
}
// TestFilePathSanitization 钉住平台返回路径的形态校验:可疑路径一律拒绝,绝不拼进
// 下载地址。
func TestFilePathSanitization(t *testing.T) {
t.Parallel()
for _, bad := range []string{"", " ", "https://evil.example.com/x", "../../etc/passwd", "a\\b.txt", "x.txt?q=1", "x\n.txt"} {
if _, err := sanitizeFilePath(bad); err == nil {
t.Fatalf("sanitizeFilePath(%q) = nil, want error", bad)
}
}
if got, err := sanitizeFilePath("documents/file_1.txt"); err != nil || got != "documents/file_1.txt" {
t.Fatalf("sanitizeFilePath(合法路径) = %q, %v", got, err)
}
}