Files
knowledge-governance/internal/releaseadapter/v071/remap_test.go
T
姚章浩 6440e3f679 feat(release): 发布目标知识库可选(源库 → 目标库映射)
- 迁移 000044:production_kb_bindings + release_item_executions.target_knowledge_base_id
- 系统设置新增「生产知识库绑定(对内/对外)」:从生产 WeKnora 实际列表选库,
  保存时真校验(不存在拒收;生产端不可达只提示并如实回报),仅管理员可改
- 执行计划按绑定解析目标库并冻结进 release_item_executions;空目标直接拒收;
  已发布过的条目保留原目标(重试/回滚回到同一处)
- DocumentBundle.RemapKnowledgeBase:knowledges/chunks/knowledge_tags/wiki_pages/
  wiki_folders 的 knowledge_base_id 与 ES 文档字段改写为目标库,重算表/文档摘要并重新封装
- 契约比对改为「声明的映射对」(KnowledgeBaseMapping),其余字段继续 fail-closed
- 契约检查器 / 目标身份 / 验证器全部走映射后的目标库;对象通道无需改写
- Compose 拆分:数据面接线 compose.release-sim.yaml + 两道门禁 compose.release-gates.yaml
- 测试:RemapKnowledgeBase 全通道/无操作/fail-closed、映射契约、绑定接口、RBAC、真实 PG 集成
2026-10-08 15:41:13 +08:00

176 lines
7.3 KiB
Go

package v071
import (
"encoding/json"
"strings"
"testing"
releaseworkflow "knowledge-governance/internal/workflows/release"
)
// crossBaseBundle is an export fixture whose rows and documents carry the base
// identity the way a real v0.7.1 export does (to_jsonb of every column), which
// is what a 源库 → 目标库 remap has to rewrite.
func crossBaseBundle(t *testing.T) *DocumentBundle {
t.Helper()
pg := PostgresSnapshot{Tables: []TableSnapshot{
newTableSnapshot("knowledges", mustCanonicalRows(t, `{"id":"knowledge-safe","knowledge_base_id":"kb-safe","title":"synthetic release marker"}`)),
newTableSnapshot("chunks", mustCanonicalRows(t,
`{"id":"chunk-safe","knowledge_base_id":"kb-safe","embedding":[0.5,-1,1024]}`,
`{"id":"chunk-two","knowledge_base_id":"kb-safe","embedding":[0.25]}`)),
newTableSnapshot("knowledge_tags", mustCanonicalRows(t, `{"id":"tag-safe","knowledge_base_id":"kb-safe"}`)),
newTableSnapshot("wiki_pages", mustCanonicalRows(t, `{"id":"page-safe","knowledge_base_id":"kb-safe"}`)),
newTableSnapshot("wiki_folders", mustCanonicalRows(t, `{"id":"folder-safe","knowledge_base_id":"kb-safe"}`)),
newTableSnapshot("resources", mustCanonicalRows(t, `{"id":"res-safe","physical_path":"storage://backend/oss://bucket/weknora/10000/knowledge-safe/asset"}`)),
}}
search := SearchSnapshot{Documents: []SearchDocument{{
ID: "es-safe",
Source: mustCanonicalRows(t, `{"tenant_id":10000,"knowledge_base_id":"kb-safe","knowledge_id":"knowledge-safe","embedding":[0.5,-1,1024]}`)[0],
}}}
search.DocumentsSHA256, _ = searchDocumentsDigest(search.Documents)
search.MappingSHA256 = strings.Repeat("a", 64)
exporter, err := NewDocumentExporter(DocumentExporterConfig{
Contract: &stubSourceContract{contract: sourceContractFixture(search.MappingSHA256)},
Postgres: &stubPGExporter{snapshot: pg}, Search: &stubSearchExporter{snapshot: search},
Objects: &stubObjectExporter{snapshot: ObjectSnapshot{SHA256: objectSnapshotDigest(nil)}},
TenantID: 10000, EmbeddingModel: "bge-m3", Now: fixedArchiveTime,
})
if err != nil {
t.Fatal(err)
}
value, err := exporter.Export(t.Context(), releasePlanForArchive())
if err != nil {
t.Fatal(err)
}
return value.(*DocumentBundle)
}
func rowField(t *testing.T, row json.RawMessage, field string) string {
t.Helper()
var object map[string]json.RawMessage
if err := json.Unmarshal(row, &object); err != nil {
t.Fatal(err)
}
var value string
if err := json.Unmarshal(object[field], &value); err != nil {
t.Fatal(err)
}
return value
}
// The whole point of the production binding: a knowledge verified in one base
// can be written into a DIFFERENT one, with every row and document moved over
// and the bundle re-sealed so the fail-closed checks still hold.
func TestRemapKnowledgeBaseRewritesAllChannelsAndReseals(t *testing.T) {
bundle := crossBaseBundle(t)
sourceBundleSHA := bundle.Manifest.BundleSHA256
chunkBefore := string(bundle.Postgres.Table("chunks").Rows[0])
resourceBefore := string(bundle.Postgres.Table("resources").Rows[0])
if err := bundle.RemapKnowledgeBase("prod-kb"); err != nil {
t.Fatalf("remap: %v", err)
}
// The manifest keeps the SOURCE base: that is where the content came from and
// what the release item's tested base is checked against.
if got := bundle.Manifest.Contract.KnowledgeBaseID; got != "kb-safe" {
t.Fatalf("manifest base = %q, want the source base", got)
}
for _, name := range remappableTables {
table := bundle.Postgres.Table(name)
if len(table.Rows) == 0 {
t.Fatalf("table %s lost its rows", name)
}
for _, row := range table.Rows {
if got := rowField(t, row, knowledgeBaseField); got != "prod-kb" {
t.Fatalf("%s row base = %q, want prod-kb", name, got)
}
if strings.Contains(string(row), "kb-safe") {
t.Fatalf("%s row still mentions the source base: %s", name, row)
}
}
}
// Tables without a base column are carried through untouched.
if got := string(bundle.Postgres.Table("resources").Rows[0]); got != resourceBefore {
t.Fatalf("resources row changed: %s", got)
}
// Embeddings must survive byte-for-byte: only the identity field is rewritten.
var before, after map[string]json.RawMessage
if err := json.Unmarshal([]byte(chunkBefore), &before); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(bundle.Postgres.Table("chunks").Rows[0], &after); err != nil {
t.Fatal(err)
}
if string(before["embedding"]) != string(after["embedding"]) {
t.Fatalf("embedding payload was re-encoded: %s -> %s", before["embedding"], after["embedding"])
}
document := bundle.Search.Documents[0]
if got := rowField(t, document.Source, knowledgeBaseField); got != "prod-kb" {
t.Fatalf("document base = %q, want prod-kb", got)
}
// Every digest the target verifies against is recomputed and re-sealed.
if err := validateDocumentBundle(bundle); err != nil {
t.Fatalf("remapped bundle must stay self-consistent: %v", err)
}
recomputed, err := searchDocumentsDigest(bundle.Search.Documents)
if err != nil || recomputed != bundle.Search.DocumentsSHA256 || recomputed != bundle.Manifest.Search.SHA256 {
t.Fatalf("search digest not coherent: %v %s %s", err, recomputed, bundle.Manifest.Search.SHA256)
}
if bundle.Manifest.BundleSHA256 == sourceBundleSHA {
t.Fatal("bundle digest must change when the content identity changes")
}
}
// A same-base release (the historical semantics) must not touch the bundle at all.
func TestRemapKnowledgeBaseIsNoOpForSameBase(t *testing.T) {
bundle := crossBaseBundle(t)
before := bundle.Manifest.BundleSHA256
if err := bundle.RemapKnowledgeBase("kb-safe"); err != nil {
t.Fatal(err)
}
if bundle.Manifest.BundleSHA256 != before {
t.Fatalf("same-base remap changed the bundle digest")
}
}
func TestRemapKnowledgeBaseFailsClosed(t *testing.T) {
tests := []struct {
name string
mutate func(*DocumentBundle)
target string
}{
{name: "invalid target", target: "prod/kb", mutate: func(*DocumentBundle) {}},
{name: "empty target", target: "", mutate: func(*DocumentBundle) {}},
{name: "row outside the source base", target: "prod-kb", mutate: func(bundle *DocumentBundle) {
bundle.Postgres = replaceTable(bundle.Postgres, newTableSnapshot("chunks", mustCanonicalRows(t, `{"id":"chunk-safe","knowledge_base_id":"other-kb"}`)))
}},
{name: "row without an identity", target: "prod-kb", mutate: func(bundle *DocumentBundle) {
bundle.Postgres = replaceTable(bundle.Postgres, newTableSnapshot("knowledges", mustCanonicalRows(t, `{"id":"knowledge-safe"}`)))
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
bundle := crossBaseBundle(t)
tt.mutate(bundle)
if err := bundle.RemapKnowledgeBase(tt.target); err == nil {
t.Fatal("RemapKnowledgeBase() error=nil")
}
})
}
if err := (*DocumentBundle)(nil).RemapKnowledgeBase("prod-kb"); err == nil {
t.Fatal("nil bundle must be rejected")
}
}
// TargetBase is what every target-side write keys on.
func TestPlanItemTargetBaseFallsBackToTestedBase(t *testing.T) {
mapped := releaseworkflow.PlanItem{TestKnowledgeBaseID: "kb-safe", TargetKnowledgeBaseID: "prod-kb"}
if mapped.TargetBase() != "prod-kb" {
t.Fatalf("mapped TargetBase = %q", mapped.TargetBase())
}
unbound := releaseworkflow.PlanItem{TestKnowledgeBaseID: "kb-safe"}
if unbound.TargetBase() != "kb-safe" {
t.Fatalf("unbound TargetBase = %q, want the tested base", unbound.TargetBase())
}
}