6440e3f679
- 迁移 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 集成
176 lines
7.3 KiB
Go
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())
|
|
}
|
|
}
|