517d5f862d
目标非空又没有生效版本时,首次发布以前只能停在"权威版本未知"。现在有了 真实入口:管理员发起 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 逐项缺失;反向自检(把候选改回按最新归档取、把文案改回旧句、 去掉管理员判断)三项均按预期变红后复原。
163 lines
7.0 KiB
Go
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"])
|
|
}
|
|
}
|