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 集成
168 lines
7.0 KiB
Go
168 lines
7.0 KiB
Go
package releasebundle
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestSealAndValidateV071Manifest(t *testing.T) {
|
|
manifest := Manifest{
|
|
FormatVersion: "weknora-release-bundle/v1",
|
|
ExecutionID: "rx-safe", PublicationID: "tp-safe", SourceItemVersionID: "sv-safe",
|
|
SourceRevision: "source-v1", ParseRevision: "parser-v1@index-v1", ReleaseRevision: "release-v1",
|
|
CreatedAt: time.Date(2026, 9, 6, 8, 0, 0, 0, time.UTC),
|
|
Contract: Contract{
|
|
WeKnoraVersion: "v0.7.1", MigrationVersion: 74, TenantID: 10000,
|
|
KnowledgeBaseID: "kb-safe", KnowledgeID: "knowledge-safe",
|
|
RetrieverEngine: "elasticsearch_v8", EmbeddingModel: "bge-m3", EmbeddingDimension: 1024,
|
|
ElasticsearchMappingSHA256: strings.Repeat("a", 64),
|
|
},
|
|
Tables: []TableDigest{
|
|
{Name: "chunks", RowCount: 2, SHA256: strings.Repeat("b", 64)},
|
|
{Name: "knowledge_tags", RowCount: 1, SHA256: strings.Repeat("f", 64)},
|
|
{Name: "knowledges", RowCount: 1, SHA256: strings.Repeat("c", 64)},
|
|
},
|
|
Search: SearchDigest{DocumentCount: 2, SHA256: strings.Repeat("d", 64)},
|
|
Assets: AssetDigest{ObjectCount: 1, TotalBytes: 42, SHA256: strings.Repeat("e", 64)},
|
|
}
|
|
sealed, err := Seal(manifest)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(sealed.BundleSHA256) != 64 {
|
|
t.Fatalf("bundle sha=%q", sealed.BundleSHA256)
|
|
}
|
|
target := TargetContract{
|
|
WeKnoraVersion: "v0.7.1", MigrationVersion: 74, TenantID: 10000,
|
|
KnowledgeBaseID: "kb-safe", RetrieverEngine: "elasticsearch_v8",
|
|
EmbeddingModel: "bge-m3", EmbeddingDimension: 1024,
|
|
ElasticsearchMappingSHA256: strings.Repeat("a", 64),
|
|
}
|
|
if err := Validate(sealed, target, sameIDMapping()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
encoded, err := json.Marshal(sealed)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, forbidden := range []string{"endpoint", "password", "secret", "api_key", "physical_path", "database_url"} {
|
|
if strings.Contains(strings.ToLower(string(encoded)), forbidden) {
|
|
t.Fatalf("manifest leaked forbidden field %q", forbidden)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSealCanonicalizesTableOrder(t *testing.T) {
|
|
base := validManifest()
|
|
first, err := Seal(base)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
base.Tables[0], base.Tables[1] = base.Tables[1], base.Tables[0]
|
|
second, err := Seal(base)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if first.BundleSHA256 != second.BundleSHA256 {
|
|
t.Fatalf("digest differs: %s vs %s", first.BundleSHA256, second.BundleSHA256)
|
|
}
|
|
}
|
|
|
|
func TestValidateRejectsContractDriftAndUnknownTables(t *testing.T) {
|
|
manifest, err := Seal(validManifest())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
target := validTarget()
|
|
tests := []struct {
|
|
name string
|
|
mutate func(*Manifest, *TargetContract)
|
|
}{
|
|
{name: "version", mutate: func(_ *Manifest, target *TargetContract) { target.WeKnoraVersion = "v0.7.2" }},
|
|
{name: "migration", mutate: func(_ *Manifest, target *TargetContract) { target.MigrationVersion = 73 }},
|
|
{name: "kb", mutate: func(_ *Manifest, target *TargetContract) { target.KnowledgeBaseID = "other" }},
|
|
{name: "embedding", mutate: func(_ *Manifest, target *TargetContract) { target.EmbeddingDimension = 768 }},
|
|
{name: "mapping", mutate: func(_ *Manifest, target *TargetContract) { target.ElasticsearchMappingSHA256 = strings.Repeat("f", 64) }},
|
|
{name: "unknown table", mutate: func(manifest *Manifest, _ *TargetContract) {
|
|
manifest.Tables = append(manifest.Tables, TableDigest{Name: "users", RowCount: 1, SHA256: strings.Repeat("1", 64)})
|
|
}},
|
|
{name: "tampered bundle", mutate: func(manifest *Manifest, _ *TargetContract) { manifest.Search.DocumentCount++ }},
|
|
{name: "missing parse revision", mutate: func(manifest *Manifest, _ *TargetContract) { manifest.ParseRevision = "" }},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
candidate, contract := manifest, target
|
|
candidate.Tables = append([]TableDigest(nil), manifest.Tables...)
|
|
tt.mutate(&candidate, &contract)
|
|
if err := Validate(candidate, contract, sameIDMapping()); err == nil {
|
|
t.Fatal("Validate() error=nil")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func validManifest() Manifest {
|
|
return Manifest{
|
|
FormatVersion: "weknora-release-bundle/v1", ExecutionID: "rx-safe", PublicationID: "tp-safe",
|
|
SourceItemVersionID: "sv-safe", SourceRevision: "source-v1", ParseRevision: "parser-v1@index-v1", ReleaseRevision: "release-v1",
|
|
CreatedAt: time.Date(2026, 9, 6, 8, 0, 0, 0, time.UTC),
|
|
Contract: Contract{WeKnoraVersion: "v0.7.1", MigrationVersion: 74, TenantID: 10000, KnowledgeBaseID: "kb-safe", KnowledgeID: "knowledge-safe", RetrieverEngine: "elasticsearch_v8", EmbeddingModel: "bge-m3", EmbeddingDimension: 1024, ElasticsearchMappingSHA256: strings.Repeat("a", 64)},
|
|
Tables: []TableDigest{{Name: "knowledges", RowCount: 1, SHA256: strings.Repeat("b", 64)}, {Name: "chunks", RowCount: 1, SHA256: strings.Repeat("c", 64)}},
|
|
Search: SearchDigest{DocumentCount: 1, SHA256: strings.Repeat("d", 64)}, Assets: AssetDigest{SHA256: strings.Repeat("e", 64)},
|
|
}
|
|
}
|
|
|
|
func validTarget() TargetContract {
|
|
return TargetContract{WeKnoraVersion: "v0.7.1", MigrationVersion: 74, TenantID: 10000, KnowledgeBaseID: "kb-safe", RetrieverEngine: "elasticsearch_v8", EmbeddingModel: "bge-m3", EmbeddingDimension: 1024, ElasticsearchMappingSHA256: strings.Repeat("a", 64)}
|
|
}
|
|
|
|
// sameIDMapping is the historical semantics: no declared target, so the
|
|
// production base must carry the tested base's id.
|
|
func sameIDMapping() KnowledgeBaseMapping {
|
|
return KnowledgeBaseMapping{SourceKnowledgeBaseID: "kb-safe"}
|
|
}
|
|
|
|
// A declared 源库 → 目标库 mapping lets the bundle land in a DIFFERENT base,
|
|
// which is the whole point of the production binding setting.
|
|
func TestValidateAcceptsDeclaredCrossBaseMapping(t *testing.T) {
|
|
manifest, err := Seal(validManifest())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
target := validTarget()
|
|
target.KnowledgeBaseID = "prod-kb"
|
|
mapping := KnowledgeBaseMapping{SourceKnowledgeBaseID: "kb-safe", TargetKnowledgeBaseID: "prod-kb"}
|
|
if err := Validate(manifest, target, mapping); err != nil {
|
|
t.Fatalf("declared cross-base release must validate: %v", err)
|
|
}
|
|
}
|
|
|
|
// An undeclared, stale or self-inconsistent mapping must fail closed: neither
|
|
// "target drifted somewhere else" nor "mapping points at the source" passes.
|
|
func TestValidateRejectsUndeclaredOrDriftedMapping(t *testing.T) {
|
|
manifest, err := Seal(validManifest())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
mapping KnowledgeBaseMapping
|
|
target string
|
|
}{
|
|
{name: "mapping source does not match bundle", mapping: KnowledgeBaseMapping{SourceKnowledgeBaseID: "other"}, target: "other"},
|
|
{name: "target differs from declared mapping", mapping: KnowledgeBaseMapping{SourceKnowledgeBaseID: "kb-safe", TargetKnowledgeBaseID: "prod-kb"}, target: "another-kb"},
|
|
{name: "mapping target differs while target keeps source id", mapping: KnowledgeBaseMapping{SourceKnowledgeBaseID: "kb-safe", TargetKnowledgeBaseID: "prod-kb"}, target: "kb-safe"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
target := validTarget()
|
|
target.KnowledgeBaseID = tt.target
|
|
if err := Validate(manifest, target, tt.mapping); err == nil {
|
|
t.Fatal("Validate() error=nil")
|
|
}
|
|
})
|
|
}
|
|
}
|