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 集成
181 lines
7.7 KiB
Go
181 lines
7.7 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"knowledge-governance/internal/governance"
|
|
"knowledge-governance/internal/platform/weknora"
|
|
)
|
|
|
|
type productionBindingStoreFake struct {
|
|
internal, external, saver string
|
|
savedInternal, savedExternal string
|
|
saveCallCount int
|
|
}
|
|
|
|
func (fake *productionBindingStoreFake) LoadProductionKBBindings(context.Context) (string, string, string, error) {
|
|
return fake.internal, fake.external, fake.saver, nil
|
|
}
|
|
|
|
func (fake *productionBindingStoreFake) SaveProductionKBBindings(_ context.Context, internal, external, updatedBy string, _ time.Time) error {
|
|
fake.savedInternal, fake.savedExternal = internal, external
|
|
fake.saver = updatedBy
|
|
fake.saveCallCount++
|
|
fake.internal, fake.external = internal, external
|
|
return nil
|
|
}
|
|
|
|
type productionListerFake struct {
|
|
bases []weknora.KnowledgeBase
|
|
err error
|
|
baseURL string
|
|
}
|
|
|
|
func (fake *productionListerFake) ListProductionKnowledgeBases(_ context.Context, baseURL string) ([]weknora.KnowledgeBase, error) {
|
|
fake.baseURL = baseURL
|
|
return fake.bases, fake.err
|
|
}
|
|
|
|
func productionBindingsHandler(t *testing.T, store ProductionBindingStore, lister ProductionKnowledgeBaseLister, settingsURL string) http.Handler {
|
|
t.Helper()
|
|
jobStore := &fakeJobStore{}
|
|
jobStore.activeUISettingsResult = governance.UISettings{ProductionWeKnoraBaseURL: settingsURL}
|
|
handler, err := NewHandler(Config{
|
|
Store: jobStore,
|
|
ProductionBindings: store, ProductionKnowledgeBases: lister,
|
|
Now: func() time.Time { return time.Date(2026, time.October, 8, 8, 0, 0, 0, time.UTC) },
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewHandler: %v", err)
|
|
}
|
|
return handler
|
|
}
|
|
|
|
func productionBases() []weknora.KnowledgeBase {
|
|
return []weknora.KnowledgeBase{
|
|
{ID: "kb-prod-internal", Name: "对内知识库-wiki", KnowledgeCount: 2069},
|
|
{ID: "kb-prod-external", Name: "对外知识库-wiki", KnowledgeCount: 526},
|
|
}
|
|
}
|
|
|
|
// The picker reads the production endpoint from 「服务配置 · 生产地址」 and reports
|
|
// the bound bases by NAME — ids never reach the page.
|
|
func TestProductionBindingsReadUsesServiceConfiguredEndpoint(t *testing.T) {
|
|
store := &productionBindingStoreFake{internal: "kb-prod-internal"}
|
|
lister := &productionListerFake{bases: productionBases()}
|
|
handler := productionBindingsHandler(t, store, lister, "https://weknora-test.dobot.cn/")
|
|
recorder := httptest.NewRecorder()
|
|
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/production-kb-bindings", nil))
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("status = %d body %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
if lister.baseURL != "https://weknora-test.dobot.cn/" {
|
|
t.Fatalf("lister baseURL = %q, want the operator-configured production address", lister.baseURL)
|
|
}
|
|
body := recorder.Body.String()
|
|
if !strings.Contains(body, "对内知识库-wiki") || !strings.Contains(body, `"production_connected":true`) {
|
|
t.Fatalf("body = %s", body)
|
|
}
|
|
}
|
|
|
|
// A chosen base that does not exist on the production endpoint must be refused
|
|
// before anything is written.
|
|
func TestProductionBindingsSaveRejectsUnknownBase(t *testing.T) {
|
|
store := &productionBindingStoreFake{}
|
|
handler := productionBindingsHandler(t, store, &productionListerFake{bases: productionBases()}, "https://weknora-test.dobot.cn/")
|
|
recorder := httptest.NewRecorder()
|
|
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodPut, "/api/v1/production-kb-bindings",
|
|
strings.NewReader(`{"internal_kb_id":"kb-ghost","external_kb_id":""}`)))
|
|
if recorder.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d body %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
if store.saveCallCount != 0 {
|
|
t.Fatal("a rejected binding must not be stored")
|
|
}
|
|
}
|
|
|
|
func TestProductionBindingsSaveStoresValidatedPair(t *testing.T) {
|
|
store := &productionBindingStoreFake{}
|
|
handler := productionBindingsHandler(t, store, &productionListerFake{bases: productionBases()}, "https://weknora-test.dobot.cn/")
|
|
recorder := httptest.NewRecorder()
|
|
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodPut, "/api/v1/production-kb-bindings",
|
|
strings.NewReader(`{"internal_kb_id":"kb-prod-internal","external_kb_id":"kb-prod-external"}`)))
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("status = %d body %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
if store.savedInternal != "kb-prod-internal" || store.savedExternal != "kb-prod-external" {
|
|
t.Fatalf("saved = %q/%q", store.savedInternal, store.savedExternal)
|
|
}
|
|
var envelope struct {
|
|
Data productionBindingView `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if envelope.Data.InternalKBName != "对内知识库-wiki" || !envelope.Data.ProductionConnected {
|
|
t.Fatalf("view = %#v", envelope.Data)
|
|
}
|
|
}
|
|
|
|
// A jittery production endpoint must not block the operator, but the response
|
|
// must say the binding could not be checked.
|
|
func TestProductionBindingsSaveWarnsWhenEndpointUnavailable(t *testing.T) {
|
|
store := &productionBindingStoreFake{}
|
|
handler := productionBindingsHandler(t, store, &productionListerFake{err: errors.New("dial tcp: timeout")}, "https://weknora-test.dobot.cn/")
|
|
recorder := httptest.NewRecorder()
|
|
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodPut, "/api/v1/production-kb-bindings",
|
|
strings.NewReader(`{"internal_kb_id":"kb-prod-internal","external_kb_id":""}`)))
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("status = %d body %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
if store.saveCallCount != 1 {
|
|
t.Fatal("an unverifiable (not invalid) binding must still be stored")
|
|
}
|
|
var envelope struct {
|
|
Data productionBindingView `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if envelope.Data.ProductionConnected || !strings.Contains(envelope.Data.ProductionMessage, "不可用") {
|
|
t.Fatalf("view = %#v", envelope.Data)
|
|
}
|
|
}
|
|
|
|
// 对内 and 对外 must not point at the same production base.
|
|
func TestProductionBindingsSaveRejectsIdenticalBases(t *testing.T) {
|
|
store := &productionBindingStoreFake{}
|
|
handler := productionBindingsHandler(t, store, &productionListerFake{bases: productionBases()}, "https://weknora-test.dobot.cn/")
|
|
recorder := httptest.NewRecorder()
|
|
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodPut, "/api/v1/production-kb-bindings",
|
|
strings.NewReader(`{"internal_kb_id":"kb-prod-internal","external_kb_id":"kb-prod-internal"}`)))
|
|
if recorder.Code != http.StatusBadRequest || store.saveCallCount != 0 {
|
|
t.Fatalf("status = %d saves = %d", recorder.Code, store.saveCallCount)
|
|
}
|
|
}
|
|
|
|
func TestProductionBindingOptionsReportUnavailableReason(t *testing.T) {
|
|
handler := productionBindingsHandler(t, &productionBindingStoreFake{}, &productionListerFake{err: errors.New("no key")}, "https://weknora-test.dobot.cn/")
|
|
recorder := httptest.NewRecorder()
|
|
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/production-kb-bindings/options", nil))
|
|
if recorder.Code != http.StatusBadGateway {
|
|
t.Fatalf("status = %d body %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
recorder = httptest.NewRecorder()
|
|
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/production-kb-bindings/options", nil))
|
|
// and with a working endpoint the options carry names, not just ids
|
|
handler = productionBindingsHandler(t, &productionBindingStoreFake{}, &productionListerFake{bases: productionBases()}, "https://weknora-test.dobot.cn/")
|
|
recorder = httptest.NewRecorder()
|
|
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/production-kb-bindings/options", nil))
|
|
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), "对外知识库-wiki") {
|
|
t.Fatalf("status = %d body %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
}
|