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) } }