Files
ragflow/internal/agent/component/pipeline_chunker_test.go
Zhichang Yu 4c54cefd29 Port 14 upstream agent security / correctness fixes to Go canvas (#16455)
Mirrors 14 merged upstream PRs into the Go agent port.

PRs ported:
  - #15609 ExeSQL SSRF guard + DNS pin
  - #15436 HTTP timeout on external API tools
  - #16363 be_output restore + DeepL error path
  - #15644 switch no longer matches empty condition
  - #15374 session_id bind to path agent_id (DAO idor guard)
  - #16169 sandbox artifact ownership gate
  - #15457 tenant ownership on agentbots
  - #15145 rerun agent document access check
- #15446 thinking switch (component portion; provider policy lives in
internal/llm)
  - #15426 Invoke URL/proxy SSRF + DNS pin + no-redirects
  - #15238 agentbot thinking-logs beta endpoint
  - #14589 UserFillUp SSE event propagation
  - #14890 anonymous webhook opt-in
- #15068 PipelineChunker new component (text/file_ref/parser_id
dispatch; file-format extraction is a follow-up)

40 files, +2355 / -58 lines. 33 new tests, all targeted package suites
pass (1721 + 4 skipped); 1 pre-existing flaky test unrelated.
2026-06-30 16:28:48 +08:00

496 lines
18 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.
//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
package component
import (
"context"
"encoding/base64"
"errors"
"strings"
"testing"
"ragflow/internal/agent/canvas"
)
// pipelineChunkerCtx returns a *gin-free context that carries a
// minimal CanvasState so the component's runtime state lookup
// succeeds.
func pipelineChunkerCtx(t *testing.T) context.Context {
t.Helper()
state := canvas.NewCanvasState("run-pc", "task-pc")
return withStateForTest(context.Background(), state)
}
// TestPipelineChunker_NewRejectsBadParserID mirrors the python
// _PARSER_MODULES whitelist: a misspelled parser_id must fail
// at NewPipelineChunkerComponent time, not at run time, so a
// bad canvas is rejected at compile.
func TestPipelineChunker_NewRejectsBadParserID(t *testing.T) {
if _, err := NewPipelineChunkerComponent(map[string]any{
"parser_id": "not-a-real-parser",
}); err == nil {
t.Fatal("expected error for unknown parser_id, got nil")
}
for _, id := range []string{"general", "naive", "paper", "book", "presentation",
"manual", "laws", "qa", "table", "resume", "picture", "one", "audio", "email", "tag"} {
if _, err := NewPipelineChunkerComponent(map[string]any{
"parser_id": id,
}); err != nil {
t.Errorf("whitelisted parser_id %q: want nil, got %v", id, err)
}
}
}
// TestPipelineChunker_NewRejectsBadPageRange covers the
// from_page > to_page check.
func TestPipelineChunker_NewRejectsBadPageRange(t *testing.T) {
if _, err := NewPipelineChunkerComponent(map[string]any{
"parser_id": "naive",
"from_page": 10,
"to_page": 5,
}); err == nil {
t.Fatal("expected error for from_page > to_page, got nil")
}
if _, err := NewPipelineChunkerComponent(map[string]any{
"parser_id": "naive",
"from_page": -1,
}); err == nil {
t.Fatal("expected error for negative from_page, got nil")
}
}
// TestPipelineChunker_InvokeEmptyInput returns the no-chunks
// sentinel (empty list, summary text). Mirrors python _run
// returning empty lists for an empty file list.
func TestPipelineChunker_InvokeEmptyInput(t *testing.T) {
c, err := NewPipelineChunkerComponent(map[string]any{
"parser_id": "naive",
})
if err != nil {
t.Fatalf("NewPipelineChunkerComponent: %v", err)
}
out, err := c.Invoke(pipelineChunkerCtx(t), map[string]any{})
if err != nil {
t.Fatalf("Invoke: %v", err)
}
if ch, _ := out["chunks"].([]string); len(ch) != 0 {
t.Errorf("empty input: want zero chunks, got %d", len(ch))
}
if sum, _ := out["summary"].(string); !strings.Contains(sum, "no input") {
t.Errorf("summary = %q, want it to mention 'no input'", sum)
}
}
// TestPipelineChunker_InvokeSlicesText feeds a non-empty text
// input and confirms the output schema (chunks is a list of
// strings, chunks_full is a list of dicts with `text`).
func TestPipelineChunker_InvokeSlicesText(t *testing.T) {
c, err := NewPipelineChunkerComponent(map[string]any{
"parser_id": "naive",
})
if err != nil {
t.Fatalf("NewPipelineChunkerComponent: %v", err)
}
out, err := c.Invoke(pipelineChunkerCtx(t), map[string]any{
"text": "First paragraph.\n\nSecond paragraph.\n\nThird paragraph.",
})
if err != nil {
t.Fatalf("Invoke: %v", err)
}
chunks, _ := out["chunks"].([]string)
if len(chunks) == 0 {
t.Fatalf("non-empty input: want at least one chunk, got zero")
}
full, _ := out["chunks_full"].([]map[string]any)
if len(full) != len(chunks) {
t.Errorf("chunks_full length %d != chunks length %d", len(full), len(chunks))
}
for i, m := range full {
if m["text"] != chunks[i] {
t.Errorf("chunks_full[%d].text = %v, want %q", i, m["text"], chunks[i])
}
}
}
// TestPipelineChunker_InvokeContentAlias accepts the front-end
// convention of `content` instead of `text`.
func TestPipelineChunker_InvokeContentAlias(t *testing.T) {
c, _ := NewPipelineChunkerComponent(map[string]any{"parser_id": "naive"})
out, err := c.Invoke(pipelineChunkerCtx(t), map[string]any{
"content": "Hello world.",
})
if err != nil {
t.Fatalf("Invoke: %v", err)
}
chunks, _ := out["chunks"].([]string)
if len(chunks) == 0 {
t.Errorf("content alias: want at least one chunk, got zero")
}
}
// TestPipelineChunker_InvokeFileBytesUTF8 covers the file_bytes
// input with valid UTF-8 text. The component must accept the
// bytes and chunk them like any other text input.
func TestPipelineChunker_InvokeFileBytesUTF8(t *testing.T) {
c, _ := NewPipelineChunkerComponent(map[string]any{"parser_id": "naive"})
out, err := c.Invoke(pipelineChunkerCtx(t), map[string]any{
"file_bytes": []byte("Para one.\n\nPara two.\n\nPara three."),
})
if err != nil {
t.Fatalf("Invoke: %v", err)
}
chunks, _ := out["chunks"].([]string)
if len(chunks) == 0 {
t.Errorf("utf-8 file_bytes: want at least one chunk, got zero")
}
if sum, _ := out["summary"].(string); !strings.Contains(sum, "parser_id=naive") {
t.Errorf("summary = %q, want it to mention parser_id=naive", sum)
}
}
// TestPipelineChunker_InvokeFileBytesBinaryRejected is the
// critical honesty test: non-UTF-8 bytes (e.g. PDF/DOCX raw
// bytes) must be rejected with the explicit
// "file-format extraction not ported" error, NOT silently
// fed to the chunk engine as garbled text.
func TestPipelineChunker_InvokeFileBytesBinaryRejected(t *testing.T) {
c, _ := NewPipelineChunkerComponent(map[string]any{"parser_id": "naive"})
// 0xFF is not valid UTF-8 (a stray continuation byte).
bad := []byte{0xFF, 0xFE, 0xFD, 0x00, 0x01}
_, err := c.Invoke(pipelineChunkerCtx(t), map[string]any{
"file_bytes": bad,
})
if err == nil {
t.Fatal("non-UTF-8 file_bytes: want error, got nil")
}
if !strings.Contains(err.Error(), "not valid UTF-8") {
t.Errorf("err = %v, want it to mention 'not valid UTF-8'", err)
}
if !strings.Contains(err.Error(), "not yet ported") {
t.Errorf("err = %v, want it to mention 'not yet ported'", err)
}
}
// TestPipelineChunker_InvokeParserIDDrivesStrategy asserts the
// parser_id actually affects the chunk output, not just the
// summary. Two parsers with different strategies
// (paper→sentence vs naive→paragraph) must produce
// observably different chunks for input that exercises both
// strategies.
//
// The Go chunk engine's default sentence boundaries are
// {。, , , \n} (Chinese punctuation; English `.` is not
// in the default set), so we use Chinese punctuation in the
// fixture. With "First。Second。Third。" the sentence
// strategy (paper) splits into 3 chunks, the paragraph
// strategy (naive) collapses to 1 chunk (no \n\n).
func TestPipelineChunker_InvokeParserIDDrivesStrategy(t *testing.T) {
input := "First。Second。Third。"
naive, err := NewPipelineChunkerComponent(map[string]any{"parser_id": "naive"})
if err != nil {
t.Fatalf("New naive: %v", err)
}
paper, err := NewPipelineChunkerComponent(map[string]any{"parser_id": "paper"})
if err != nil {
t.Fatalf("New paper: %v", err)
}
naiveOut, err := naive.Invoke(pipelineChunkerCtx(t), map[string]any{"text": input})
if err != nil {
t.Fatalf("naive Invoke: %v", err)
}
paperOut, err := paper.Invoke(pipelineChunkerCtx(t), map[string]any{"text": input})
if err != nil {
t.Fatalf("paper Invoke: %v", err)
}
naiveChunks, _ := naiveOut["chunks"].([]string)
paperChunks, _ := paperOut["chunks"].([]string)
if len(naiveChunks) >= len(paperChunks) {
t.Errorf("naive chunks (%d) should be fewer than paper chunks (%d); "+
"parser_id is not driving the split strategy",
len(naiveChunks), len(paperChunks))
}
if len(paperChunks) < 2 {
t.Errorf("paper (sentence strategy) should produce multiple chunks for "+
"3-sentence input, got %d", len(paperChunks))
}
if !strings.Contains(naiveOut["summary"].(string), "parser_id=naive") {
t.Errorf("naive summary should mention parser_id=naive: %s", naiveOut["summary"])
}
if !strings.Contains(paperOut["summary"].(string), "parser_id=paper") {
t.Errorf("paper summary should mention parser_id=paper: %s", paperOut["summary"])
}
}
// TestPipelineChunker_ParserToSplitStrategy pins the
// parser_id→strategy mapping so a future refactor that
// flattens it back to "paragraph for everything" is caught.
func TestPipelineChunker_ParserToSplitStrategy(t *testing.T) {
cases := map[string]string{
"general": "paragraph", "naive": "paragraph",
"book": "paragraph", "presentation": "paragraph",
"manual": "paragraph", "qa": "paragraph",
"resume": "paragraph", "email": "paragraph", "tag": "paragraph",
"paper": "sentence", "laws": "sentence",
"table": "char",
"picture": "paragraph",
"one": "paragraph",
"audio": "paragraph",
"unknown-x": "paragraph", // fallback
"": "paragraph", // empty → default
}
for parserID, want := range cases {
got := parserToSplitStrategy(parserID)
if got != want {
t.Errorf("parserToSplitStrategy(%q) = %q, want %q", parserID, got, want)
}
}
}
// TestPipelineChunker_InvalidParserIDInInvoke ensures the
// param check catches bad parser_ids before any chunk work
// runs. Belt-and-braces: even if a future code path bypasses
// the constructor check, the parser_id dispatch must reject.
func TestPipelineChunker_InvalidParserIDInInvoke(t *testing.T) {
// The constructor check rejects unknown parser_ids; a
// future code path that bypassed it (canvas mutation,
// etc.) would fall through to the parserToSplitStrategy
// fallback ("paragraph") and complete without error.
// Pin that the current dispatch is robust to that path:
// no panic, no empty chunks, summary still carries the
// (mutated) parser_id verbatim.
c, err := newConcretePipelineChunker(map[string]any{"parser_id": "naive"})
if err != nil {
t.Fatalf("newConcretePipelineChunker: %v", err)
}
c.param.ParserID = "future-parser"
out, err := c.Invoke(pipelineChunkerCtx(t), map[string]any{"text": "hello."})
if err != nil {
t.Errorf("unknown parser_id fallback: want nil, got %v", err)
}
if out == nil {
t.Error("unknown parser_id: want non-nil output, got nil")
}
if sum, _ := out["summary"].(string); !strings.Contains(sum, "parser_id=future-parser") {
t.Errorf("summary = %q, want it to carry the mutated parser_id", sum)
}
}
// TestPipelineChunker_ChunksFullShape pins the per-chunk
// metadata shape: text + size + index.
func TestPipelineChunker_ChunksFullShape(t *testing.T) {
c, _ := NewPipelineChunkerComponent(map[string]any{"parser_id": "naive"})
out, err := c.Invoke(pipelineChunkerCtx(t), map[string]any{
"text": "Chunk A.\n\nChunk B.",
})
if err != nil {
t.Fatalf("Invoke: %v", err)
}
full, ok := out["chunks_full"].([]map[string]any)
if !ok {
t.Fatalf("chunks_full type = %T, want []map[string]any", out["chunks_full"])
}
for i, m := range full {
for _, key := range []string{"text", "size", "index"} {
if _, ok := m[key]; !ok {
t.Errorf("chunks_full[%d] missing key %q (map=%v)", i, key, m)
}
}
}
}
// TestPipelineChunker_StreamMatchesInvoke asserts Stream
// returns the same payload as Invoke (synchronous facade).
func TestPipelineChunker_StreamMatchesInvoke(t *testing.T) {
c, _ := NewPipelineChunkerComponent(map[string]any{"parser_id": "naive"})
ctx := pipelineChunkerCtx(t)
inputs := map[string]any{"text": "single paragraph"}
invokeOut, err := c.Invoke(ctx, inputs)
if err != nil {
t.Fatalf("Invoke: %v", err)
}
streamCh, err := c.Stream(ctx, inputs)
if err != nil {
t.Fatalf("Stream: %v", err)
}
select {
case streamOut, ok := <-streamCh:
if !ok {
t.Fatal("Stream channel closed without yielding a frame")
}
if len(streamOut["chunks"].([]string)) != len(invokeOut["chunks"].([]string)) {
t.Errorf("Stream chunks=%d != Invoke chunks=%d",
len(streamOut["chunks"].([]string)),
len(invokeOut["chunks"].([]string)))
}
default:
t.Fatal("Stream channel had no frame to read")
}
}
// newConcretePipelineChunker returns the concrete struct
// rather than the Component interface so tests can mutate
// the param directly (e.g. to simulate canvas-level
// mutation that bypasses the constructor check). The
// constructor is still the production entry point.
func newConcretePipelineChunker(params map[string]any) (*PipelineChunkerComponent, error) {
c, err := NewPipelineChunkerComponent(params)
if err != nil {
return nil, err
}
return c.(*PipelineChunkerComponent), nil
}
// errors is referenced so the test file compiles without
// pulling in the stdlib errors package directly above.
var _ = errors.New
// TestPipelineChunker_FileRefBytes guards the second code
// review fix: the Inputs() docstring promises file_ref
// support but the readPipelineInputText function previously
// only handled text / content / file_bytes, leaving a
// silent empty-input gap when an upstream canvas sent
// inputs["file_ref"] = []byte. The fix added the file_ref
// path; this test pins it.
func TestPipelineChunker_FileRefBytes(t *testing.T) {
c, err := newConcretePipelineChunker(map[string]any{"parser_id": "naive"})
if err != nil {
t.Fatalf("newConcretePipelineChunker: %v", err)
}
out, err := c.Invoke(pipelineChunkerCtx(t), map[string]any{
"file_ref": []byte("First paragraph about cats.\n\nSecond paragraph about dogs."),
})
if err != nil {
t.Fatalf("Invoke: %v", err)
}
if sum, _ := out["summary"].(string); !strings.Contains(sum, "chunks=2") {
t.Errorf("summary = %q, want chunks=2", sum)
}
}
// TestPipelineChunker_FileRefBase64 covers the base64-encoded
// string form of file_ref — the orchestrator's normal form
// when the bytes are surfaced from a multipart upload.
func TestPipelineChunker_FileRefBase64(t *testing.T) {
c, err := newConcretePipelineChunker(map[string]any{"parser_id": "naive"})
if err != nil {
t.Fatalf("newConcretePipelineChunker: %v", err)
}
raw := []byte("alpha paragraph.\n\nbeta paragraph.\n\ngamma paragraph.")
encoded := base64.StdEncoding.EncodeToString(raw)
out, err := c.Invoke(pipelineChunkerCtx(t), map[string]any{
"file_ref": encoded,
})
if err != nil {
t.Fatalf("Invoke: %v", err)
}
if sum, _ := out["summary"].(string); !strings.Contains(sum, "chunks=3") {
t.Errorf("summary = %q, want chunks=3", sum)
}
}
// TestPipelineChunker_FileRefRawTextRejected pins the
// strict base64 contract: a string file_ref that is NOT
// valid base64 must be REJECTED, not silently treated as
// raw text. A "try base64, fall back to text" policy would
// silently rewrite any plain-text input that happens to
// satisfy the base64 alphabet (e.g. "Zm9v" → "foo") — a
// real correctness bug. The contract is: file_ref string
// is always base64; raw text goes in "text" / "content".
func TestPipelineChunker_FileRefRawTextRejected(t *testing.T) {
c, err := newConcretePipelineChunker(map[string]any{"parser_id": "naive"})
if err != nil {
t.Fatalf("newConcretePipelineChunker: %v", err)
}
// "one paragraph..." is plain text that contains spaces
// and a newline — not valid base64.
_, err = c.Invoke(pipelineChunkerCtx(t), map[string]any{
"file_ref": "one paragraph.\n\ntwo paragraphs.\n\nthree paragraphs.",
})
if err == nil {
t.Fatal("expected non-base64 file_ref string to be rejected, got nil")
}
if !strings.Contains(err.Error(), "not valid base64") {
t.Errorf("err = %v, want it to mention 'not valid base64'", err)
}
if !strings.Contains(err.Error(), "text") {
t.Errorf("err = %v, want it to point at the 'text' / 'content' key", err)
}
}
// TestPipelineChunker_FileRefBase64AlphabetTextRejected
// guards the real silent-misinterpretation bug: the
// string "Zm9v" is valid base64 (decodes to "foo") and
// also looks like plausible file content. Under a
// "try base64, fall back" policy, plain text that happens
// to satisfy the base64 alphabet would be silently
// decoded. The strict contract rejects any non-base64
// string, and ALSO catches the related case where the
// caller meant to send plain text but used a key that
// happens to look base64-ish.
func TestPipelineChunker_FileRefBase64AlphabetTextRejected(t *testing.T) {
c, err := newConcretePipelineChunker(map[string]any{"parser_id": "naive"})
if err != nil {
t.Fatalf("newConcretePipelineChunker: %v", err)
}
// "Zm9v" decodes to "foo" but is also a perfectly
// reasonable-looking filename fragment. Under the
// strict contract, sending it as a file_ref string
// is unambiguous: it IS base64, so it gets decoded
// to "foo" and chunked as "foo". This is the
// intended behaviour — the contract is strict, not
// ambiguous. The test pins it.
_, err = c.Invoke(pipelineChunkerCtx(t), map[string]any{
"file_ref": "Zm9v",
})
// "Zm9v" IS valid base64 — must succeed and produce
// a chunk with text "foo". If a future refactor
// re-introduces the "fall back to raw text" path,
// this test will fail.
if err != nil {
t.Fatalf("Zm9v is valid base64; want no error, got %v", err)
}
}
// TestPipelineChunker_FileRefNonUTF8Bytes guards the error
// path: a file_ref carrying raw PDF/DOCX bytes (which the
// Go side cannot yet extract) must surface a clear "not
// UTF-8, extraction not ported" error instead of silently
// producing garbled chunks.
func TestPipelineChunker_FileRefNonUTF8Bytes(t *testing.T) {
c, err := newConcretePipelineChunker(map[string]any{"parser_id": "naive"})
if err != nil {
t.Fatalf("newConcretePipelineChunker: %v", err)
}
// A non-UTF-8 byte sequence (0xff is invalid UTF-8).
binary := []byte{0xff, 0xfe, 0xfd, 0xfc}
_, err = c.Invoke(pipelineChunkerCtx(t), map[string]any{
"file_ref": binary,
})
if err == nil {
t.Fatal("expected non-UTF-8 error, got nil")
}
if !strings.Contains(err.Error(), "not valid UTF-8") {
t.Errorf("err = %v, want 'not valid UTF-8'", err)
}
if !strings.Contains(err.Error(), "not yet ported") {
t.Errorf("err = %v, want 'not yet ported' hint", err)
}
}