Files
knowledge-governance/internal/httpapi/library_baseline_test.go
姚章浩 517d5f862d feat(library): 首次发布接上目标权威基线与整库空判定
目标非空又没有生效版本时,首次发布以前只能停在"权威版本未知"。现在有了
真实入口:管理员发起 baseline 作业,worker 在维护隔离下把目标整库捕获成
归档快照,再用同一套 Verify 证明目标与快照逐类一致,最后登记
production_effective(证据写明 origin=target_baseline、快照 ID、逐表行数、
目标索引映射)。不写目标内容、不清空目标、不手写版本。

同时修两处会误导人的判定:
- productionScopeEmpty 原来只数 knowledges,"文档删了但分块/Wiki/资源还在"
  的目标会被当成空库;改用适配层同一份表清单与范围过滤逐表计数。
- 概览里的"测试封存候选"原来取该 scope 最新一份归档,而归档里还有生产
  预备份与目标基线快照;改为只认版本登记里 kind=test_sealed 的那一份。

迁移 075 放开 kind='baseline' 并把它并入 sync/restore 的写互斥索引;
worker 重启时用同一作业 id 接管未完成的基线窗口。维护接口补 capability,
页面才能区分"未运行维护"与"维护能力没接线"。

验证:go build ./... / go vet ./... 通过;新增用例覆盖管理员校验、已有生效
版本拒绝、目标读不到时零作业零版本、候选来自登记、缺项文案指向基线入口、
capability 逐项缺失;反向自检(把候选改回按最新归档取、把文案改回旧句、
去掉管理员判断)三项均按预期变红后复原。
2026-10-29 18:16:21 +08:00

163 lines
7.0 KiB
Go

package httpapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"knowledge-governance/internal/governance"
)
// 建立目标权威基线的入口契约:
// - 只有管理员能发起(它是生产侧维护动作);
// - 已有生效版本时拒绝(基线只解决首次发布);
// - 目标读不到时拒绝,且**不建作业、不登记版本**(不能用基线掩盖连接问题)。
func baselineRequest(body, role, username string) *http.Request {
request := httptest.NewRequest(http.MethodPost, "/api/v1/library/baselines", strings.NewReader(body))
request.Header.Set("Content-Type", "application/json")
ctx := context.WithValue(request.Context(), sessionKey{}, sessionIdentity{Username: username, Role: role})
return request.WithContext(ctx)
}
func decodeBody(t *testing.T, recorder *httptest.ResponseRecorder) map[string]any {
t.Helper()
var payload map[string]any
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
t.Fatalf("response is not JSON: %s", recorder.Body.String())
}
return payload
}
func TestLibraryBaselineRequiresAdmin(t *testing.T) {
store := &fakeLibraryStore{}
recorder := httptest.NewRecorder()
libraryBaselineEnqueue(recorder, baselineRequest(`{"scope":"internal"}`, roleEngineer, "eng"), Config{Store: store})
if recorder.Code != http.StatusForbidden {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
if store.createN != 0 {
t.Fatal("non-admin must not create a baseline job")
}
}
func TestLibraryBaselineRefusesWhenEffectiveVersionExists(t *testing.T) {
store := &fakeLibraryStore{versions: []governance.LibraryVersion{{
ID: "libver-1", Scope: "internal", Kind: governance.LibraryVersionProductionEffective,
SnapshotID: "snap-effective", CreatedAt: time.Now().UTC(),
}}}
recorder := httptest.NewRecorder()
libraryBaselineEnqueue(recorder, baselineRequest(`{"scope":"internal"}`, roleAdmin, "admin"), Config{Store: store})
if recorder.Code != http.StatusConflict {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
if body := decodeBody(t, recorder); body["code"] != "library_baseline_exists" || body["snapshot_id"] != "snap-effective" {
t.Fatalf("body=%v", body)
}
if store.createN != 0 {
t.Fatal("existing effective version must not create another baseline job")
}
}
// 目标读不到(这里用"没配置目标库"复现):必须 503 并说明原因,
// 既不能建作业,也不能顺手写一条版本记录来"解决"未知状态。
func TestLibraryBaselineRefusesWhenTargetUnreadable(t *testing.T) {
store := &fakeLibraryStore{}
recorder := httptest.NewRecorder()
libraryBaselineEnqueue(recorder, baselineRequest(`{"scope":"external"}`, roleAdmin, "admin"), Config{Store: store})
if recorder.Code != http.StatusServiceUnavailable {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
body := decodeBody(t, recorder)
if body["code"] != "library_baseline_unprobeable" || !strings.Contains(func() string {
text, _ := body["error"].(string)
return text
}(), "无法读取目标整库状态") {
t.Fatalf("body=%v", body)
}
if store.createN != 0 {
t.Fatal("unreadable target must not create a baseline job")
}
if len(store.versions) != 0 {
t.Fatal("unreadable target must not record any version")
}
}
// 测试候选只认登记过 test_sealed 的那份快照:归档里同时存在更新的目标基线/
// 预备份快照时,绝不能把它们当成"拟发布的测试版本"。
func TestTestSealedCandidateComesFromRegistryNotNewestArchiveEntry(t *testing.T) {
sealed := librarySnapshotView{SnapshotID: "snap-sealed", CreatedAt: time.Date(2026, 10, 29, 8, 54, 0, 0, time.UTC)}
prebackup := librarySnapshotView{SnapshotID: "snap-prebackup", CreatedAt: time.Date(2026, 10, 29, 9, 30, 0, 0, time.UTC)}
byID := map[string]librarySnapshotView{sealed.SnapshotID: sealed, prebackup.SnapshotID: prebackup}
versions := []governance.LibraryVersion{
{Kind: governance.LibraryVersionProductionPrebackup, SnapshotID: "snap-prebackup"},
{Kind: governance.LibraryVersionTestSealed, SnapshotID: "snap-sealed"},
}
candidate, reason := testSealedOf(versions, byID)
if reason != "" {
t.Fatalf("reason=%q", reason)
}
if candidate == nil || candidate.SnapshotID != "snap-sealed" {
t.Fatalf("candidate=%v, want the registered test seal", candidate)
}
// 没有任何 test_sealed 登记:宁可说"还没有测试封存",也不拿别的快照顶替。
none, reason := testSealedOf([]governance.LibraryVersion{
{Kind: governance.LibraryVersionProductionPrebackup, SnapshotID: "snap-prebackup"},
}, byID)
if none != nil || !strings.Contains(reason, "还没有该目标的测试封存") {
t.Fatalf("candidate=%v reason=%q", none, reason)
}
// 登记过但它已经不在本机归档:说清是归档问题,不给候选。
missing, reason := testSealedOf([]governance.LibraryVersion{
{Kind: governance.LibraryVersionTestSealed, SnapshotID: "snap-gone"},
}, byID)
if missing != nil || !strings.Contains(reason, "不在本机归档里") {
t.Fatalf("candidate=%v reason=%q", missing, reason)
}
}
// 非空但无权威版本时,缺项必须指向真实入口,而不是一句"未知"。
func TestLibrarySyncGateNamesTheBaselineEntryForNonEmptyTarget(t *testing.T) {
gate := librarySyncGateWithGrant(Config{ProductionReleaseRequestsEnabled: true,
ProductionBackupRestoreVerified: true, LibraryProductionDatabaseURL: "postgres://prod.invalid/db",
LibraryProductionInternalKB: "kb-int", LibraryCapacityBudgetBytes: 1 << 30},
libraryKnownView{State: "unknown"}, libraryKnownView{State: "unknown"}, "snap-1", true)
if gate.SyncAllowed {
t.Fatal("unknown production version must block the sync")
}
joined := strings.Join(gate.Reasons, "|")
if !strings.Contains(joined, "目标非空且没有生效版本记录") || !strings.Contains(joined, "目标权威基线") {
t.Fatalf("reasons=%v", gate.Reasons)
}
}
// 维护能力是配置事实:配置齐全时必须说"已接线",缺项要列出缺哪个变量。
func TestLibraryMaintenanceCapabilityReportsWiringInsteadOfOneVagueSentence(t *testing.T) {
wired := libraryMaintenanceCapability(Config{
LibraryTargetInstance: "kgseal-app", LibraryTargetProject: "kgseal-weknora",
LibraryTargetWriters: []string{"app"}, LibraryTargetIngressURL: "http://kgseal-ingress:18084/health",
LibraryTargetIngressService: "ingress", LibraryTargetJournalRoot: "/var/lib/knowledge-governance/journal",
LibraryTargetDockerSocket: "/var/run/docker.sock",
})
if wired["configured"] != true {
t.Fatalf("capability=%v", wired)
}
if wired["instance"] != "kgseal-app" {
t.Fatalf("instance=%v", wired["instance"])
}
broken := libraryMaintenanceCapability(Config{LibraryTargetInstance: "kgseal-app"})
if broken["configured"] != false {
t.Fatalf("capability=%v", broken)
}
missing, _ := broken["missing"].([]string)
if len(missing) == 0 || !strings.Contains(strings.Join(missing, ","), "LIBRARY_TARGET_WRITERS") {
t.Fatalf("missing=%v", broken["missing"])
}
}