package app import ( "archive/zip" "bytes" "context" "encoding/json" "path/filepath" "testing" "time" agentcommand "agent-command" "agent-system-backend/internal/cmdbus" "agent-system-backend/internal/rag" "agent-system-backend/internal/settings" "agent-system-backend/internal/store" "agent-system-backend/pkg/extension" core "agent-system-core" ) // newInprocTestApp 组合根测试版:走真实的 applyCompiled → cmdbus.Dispatch → // inprocHost 链路(卡片端口 nil,仅普通危险级命令;embedder 非 nil 时为 hash 档)。 func newInprocTestApp(t *testing.T, embedder func() string) (*cmdbus.Bus, *store.Store) { t.Helper() st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "test.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { st.Close() }) set := settings.NewService(st) pipe := rag.NewPipeline(st, set, nil, nil) if embedder != nil { pipe.SetEmbedder(rag.SwitchingEmbedder{Provider: embedder}) } bus := cmdbus.New(st, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() }) caps := hostcaps{st: st, pipe: pipe, set: set} applyCompiled(filepath.Join(t.TempDir(), "skills"), bus, caps) return bus, st } // TestInprocHostOwnerScope 是 D8/H4 烟测的单元级前置:两个 owner 各分发一次, // 审计、知识、KV、任务台账必须各落自己 owner 名下(D8 烟测的 PG 版另行)。 func TestInprocHostOwnerScope(t *testing.T) { extension.ResetCompiled() t.Cleanup(extension.ResetCompiled) extension.RegisterCompiled(extension.InProc{ ID: "tst", Register: func(reg extension.Registry) error { return reg.AddCommandHandler(json.RawMessage(`{"id":"tst.record","title":"记录"}`), func(ctx context.Context, h extension.Host, req extension.Request) (any, error) { ref, _ := req.Params["ref"].(string) if err := h.Audit("tst.record", ref, "{}"); err != nil { return nil, err } if err := h.KnowledgeWrite(ctx, []core.KnowledgeDoc{ {Title: "分块", Ref: ref, Chunk: string(h.Owner()) + " 的入库内容"}, }, true); err != nil { return nil, err } if err := h.KVPut("last", string(h.Owner())); err != nil { return nil, err } taskID, err := h.TaskCreate("tst.record", "记录 "+ref, req.ConversationID) if err != nil { return nil, err } if err := h.TaskUpdate(taskID, "succeeded", 100, ""); err != nil { return nil, err } hits, err := h.KnowledgeSearch(ctx, "", string(h.Owner())+" 的入库内容", 5) if err != nil { return nil, err } return map[string]any{"owner": h.Owner(), "plugin": h.PluginID(), "hits": len(hits)}, nil }) }, }) bus, st := newInprocTestApp(t, nil) // embedder nil:embed=true 仍只落全文(IndexChunks 降级路径) for _, owner := range []string{"alice", "bob"} { if err := st.EnsureUser(owner, ""); err != nil { t.Fatal(err) } resp := bus.Dispatch(context.Background(), cmdbus.Request{ ID: "tst.record", Params: map[string]any{"ref": owner + "-ref"}, Owner: owner, Actor: "ui", Entrance: "chat", }) if resp.Receipt.State != agentcommand.StateCommitted { t.Fatalf("%s dispatch: %+v", owner, resp.Receipt) } m, _ := resp.Payload.(map[string]any) if m["owner"] != owner || m["plugin"] != "tst" || m["hits"] != 1 { t.Fatalf("%s payload: %+v", owner, resp.Payload) } } // 审计各归其主,Actor 标注插件身份。 for _, owner := range []string{"alice", "bob"} { evs, err := st.ListAuditEvents(owner, 10) if err != nil { t.Fatal(err) } if len(evs) != 1 || evs[0].Owner != owner || evs[0].Actor != "plugin:tst" || evs[0].Action != "tst.record" { t.Fatalf("%s audit: %+v", owner, evs) } } // 知识各归其主:本人可见,他人检索不可见(visibility 过滤)。 aHits, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "alice 的入库内容"}) if err != nil { t.Fatal(err) } if len(aHits) != 1 || string(aHits[0].Doc.Owner) != "alice" { t.Fatalf("alice knowledge: %+v", aHits) } cross, err := st.SearchKnowledge("bob", core.RetrievalQuery{Text: "alice 的入库内容"}) if err != nil { t.Fatal(err) } if len(cross) != 0 { t.Fatalf("owner leak in knowledge: %+v", cross) } // KV 按 pluginID+owner 隔离。 for _, owner := range []string{"alice", "bob"} { v, ok, err := st.GetPluginKV("tst", owner, "last") if err != nil || !ok || v != owner { t.Fatalf("%s kv: %q %v %v", owner, v, ok, err) } if _, ok, _ := st.GetPluginKV("other-plugin", owner, "last"); ok { t.Fatalf("%s kv leaked across plugins", owner) } } // 任务台账各归其主且状态已更新。 for _, owner := range []string{"alice", "bob"} { tasks, err := st.ListBackgroundTasks(owner) if err != nil { t.Fatal(err) } if len(tasks) != 1 || tasks[0].Kind != "tst.record" || tasks[0].Status != "succeeded" || tasks[0].Owner != owner { t.Fatalf("%s tasks: %+v", owner, tasks) } } } // TestInprocHostKnowledgeWriteEmbedHash:provider=hash 档下 KnowledgeWrite(embed=true) // 写出向量行,且 Owner/Source 由基座强制改写。 func TestInprocHostKnowledgeWriteEmbedHash(t *testing.T) { extension.ResetCompiled() t.Cleanup(extension.ResetCompiled) extension.RegisterCompiled(extension.InProc{ ID: "tst", Register: func(reg extension.Registry) error { return reg.AddCommandHandler(json.RawMessage(`{"id":"tst.embed","title":"向量"}`), func(ctx context.Context, h extension.Host, req extension.Request) (any, error) { text, _ := req.Params["text"].(string) // 故意不带 Owner/Source:验证基座强制改写。 return "ok", h.KnowledgeWrite(ctx, []core.KnowledgeDoc{{Title: "向量", Ref: "vec-1", Chunk: text}}, true) }) }, }) bus, st := newInprocTestApp(t, func() string { return "hash" }) if err := st.EnsureUser("alice", ""); err != nil { t.Fatal(err) } resp := bus.Dispatch(context.Background(), cmdbus.Request{ ID: "tst.embed", Params: map[string]any{"text": "Hash 向量内容"}, Owner: "alice", Entrance: "api", }) if resp.Receipt.State != agentcommand.StateCommitted { t.Fatalf("dispatch: %+v", resp.Receipt) } // Owner 强制 alice、Source 强制补 plugin:tst。 hits, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "向量内容"}) if err != nil { t.Fatal(err) } if len(hits) != 1 || string(hits[0].Doc.Owner) != "alice" || hits[0].Doc.Source != "plugin:tst" { t.Fatalf("doc attribution: %+v", hits) } // hash 档向量行存在且可被同文本向量召回。 want, err := rag.HashEmbedder{}.Embed(context.Background(), []string{"Hash 向量内容"}) if err != nil { t.Fatal(err) } vhits, err := st.SearchKnowledgeVectors(context.Background(), "alice", want[0], 5, core.RetrievalQuery{}) if err != nil { t.Fatal(err) } if len(vhits) != 1 { t.Fatalf("vector rows: %+v", vhits) } // bob 用同一向量检索不可见。 bhits, err := st.SearchKnowledgeVectors(context.Background(), "bob", want[0], 5, core.RetrievalQuery{}) if err != nil { t.Fatal(err) } if len(bhits) != 0 { t.Fatalf("vector owner leak: %+v", bhits) } } // TestInprocHostParseDocumentOwnerScope:EXT-5 解析方法的 owner 作用域—— // alice 经句柄读自己的工件并解析;bob 用同一工件 ID 在 ArtifactContent 处 // 即被拒(解析是纯计算,作用域边界在取原件一环)。 func TestInprocHostParseDocumentOwnerScope(t *testing.T) { extension.ResetCompiled() t.Cleanup(extension.ResetCompiled) extension.RegisterCompiled(extension.InProc{ ID: "tst", Register: func(reg extension.Registry) error { return reg.AddCommandHandler(json.RawMessage(`{"id":"tst.parse","title":"解析"}`), func(ctx context.Context, h extension.Host, req extension.Request) (any, error) { id, _ := req.Params["artifactId"].(string) content, err := h.ArtifactContent(id) if err != nil { return nil, err } doc, err := h.ParseDocument(ctx, "报价.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content) if err != nil { return nil, err } return map[string]any{ "parser": doc.Meta.Parser, "warnings": len(doc.Warnings), "blocks": len(doc.Blocks), }, nil }) }, }) bus, st := newInprocTestApp(t, nil) art := core.Artifact{ProjectID: "p1", Owner: "alice", Name: "报价.xlsx", Kind: "document"} if err := st.CreateArtifact(&art, testXLSXBytes(t)); err != nil { t.Fatal(err) } artID := art.ID resp := bus.Dispatch(context.Background(), cmdbus.Request{ ID: "tst.parse", Params: map[string]any{"artifactId": artID}, Owner: "alice", Entrance: "chat", }) if resp.Receipt.State != agentcommand.StateCommitted { t.Fatalf("alice dispatch: %+v %s", resp.Receipt, resp.Receipt.Reason) } m, _ := resp.Payload.(map[string]any) if m["parser"] != "builtin:xlsx" || m["warnings"] != 0 || m["blocks"] == 0 { t.Fatalf("alice payload: %+v", m) } // bob 同一工件 ID:取原件即被拒(跨 owner 不可见)。 resp = bus.Dispatch(context.Background(), cmdbus.Request{ ID: "tst.parse", Params: map[string]any{"artifactId": artID}, Owner: "bob", Entrance: "chat", }) if resp.Receipt.State == agentcommand.StateCommitted { t.Fatalf("bob must not read alice's artifact: %+v", resp.Payload) } } // TestInprocHostParseDocumentLimits:仅有的两条 error 路径——ctx 取消与输入 // 超过 100MiB 上限;不可解析格式仍是带 Warnings 的显式失败。 func TestInprocHostParseDocumentLimits(t *testing.T) { st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "t.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { st.Close() }) h := inprocHost{caps: hostcaps{st: st}, pluginID: "tst", owner: "alice"} ctx, cancel := context.WithCancel(context.Background()) cancel() if _, err := h.ParseDocument(ctx, "a.xlsx", "", []byte("x")); err == nil { t.Fatal("canceled ctx must error") } if _, err := h.ParseDocument(context.Background(), "a.xlsx", "", make([]byte, maxParseInput+1)); err == nil { t.Fatal("oversized input must error") } doc, err := h.ParseDocument(context.Background(), "旧.xls", "", []byte{0xD0, 0xCF}) if err != nil { t.Fatalf("legacy format is a visible failure, not an error: %v", err) } if doc.Meta.Parser != "builtin:legacy-office" || len(doc.Warnings) != 1 { t.Fatalf("legacy doc: %+v %v", doc.Meta, doc.Warnings) } } // testXLSXBytes 构造最小可用 xlsx(单表两行),供句柄测试解析。 func testXLSXBytes(t *testing.T) []byte { t.Helper() var buf bytes.Buffer zw := zip.NewWriter(&buf) files := map[string]string{ "xl/workbook.xml": ``, "xl/_rels/workbook.xml.rels": ``, "xl/worksheets/sheet1.xml": ` 物料单价 支架12.5 `, } for _, name := range []string{"xl/workbook.xml", "xl/_rels/workbook.xml.rels", "xl/worksheets/sheet1.xml"} { w, err := zw.Create(name) if err != nil { t.Fatal(err) } w.Write([]byte(files[name])) } if err := zw.Close(); err != nil { t.Fatal(err) } return buf.Bytes() }