Compare commits

...

8 Commits

Author SHA1 Message Date
姚章浩 47be4ae502 fix(web): 未读到目标观测不再误报非空并标注旧观测
build / build (push) Successful in 1m46s
根因:ReleaseTargetsView 在 overview 缺失时自己补 {state:'unknown'},而
productionTargetClass 把"unknown 且没有 empty_baseline"一律当成非空。后端对
"已探测且非空"发的正是这个形状(Go struct 上 omitempty),所以页面本地默认值
与真实观测在 JSON 上无法区分:首次加载、整库状态读取失败、当前范围没有数据
三条路径都会显示"目标非空,尚无权威版本",管理员还会拿到会返回 409 的
"建立目标权威基线"入口。

修法:没有 overview 时 production 保持 null,分类走新的 no-observation 分支;
四类有效响应(known / 已读且为空 / 非空无版本 / 不可读)的判定与入口不变。
刷新失败保留上一份 overview 时标注"上次观测 · 刷新失败,可能已过期",不把旧
结果当成刚验证的状态。后端 gate、权限、预备份与 API 拒绝一律未改。

测试:把旧"三次 SSR 当刷新"的用例撤掉,改为同一挂载 + 可控 fetch 的真实行为
测试(首次读取未返回 / 空→不可读→恢复 / 刷新失败保留旧观测 / 内外切换 /
overview 属于别的范围);缺观测的三条路径另以 SSR 负例复用审核者的反例。
新增 jsdom 仅作为 vitest 的 DOM 环境(商品代码零改动,npm ci 与锁文件已核)。

验证:make verify 16 项 success(tests-web 504 passed、tsc 无错、npm audit 0);
新用例先在原实现上红(断言收到"目标非空"),修后绿。未部署、未合并、未改
候选运行栈。
2026-11-02 12:29:48 +08:00
姚章浩 6827fa922f fix(candidate): 夹具用产品探针证空并复用真实索引 mapping
build / build (push) Successful in 1m55s
首轮试跑暴露三处夹具缺陷(都在 test-only 文件里,产品代码未动):
- 目标骨架只核 knowledge_bases:最小夹具 schema 没有 tenants 表,改为按 KB 行数与
  产品 ScopeContentEmpty 的十表计数证空;
- 运行期索引曾把 GET _mapping 的结果再包一层 mappings,ES 400;
  现在直接回放原 mapping 文档(properties 层级与封存时完全一致);
- 目标指纹改在夹具分配桶名之后用同一份 env 计算,运行器再把桶名写进 API/worker env。

试跑两次均按 cleanup_empty_target_ui.py 精确清理并读回(resources 全不存在)。
2026-11-02 11:11:08 +08:00
姚章浩 c31a7df4ca style(test-scope): 可选范围只追加两条,恢复原有紧凑格式
build / build (push) Successful in 1m50s
上一笔把 scripts/test-scope.json 整体重排成缩进格式,diff 变成 196 行噪音。
这里恢复文件原本的一行一条格式,只保留新增的 TestCandidateEmptyTargetFixture /
TestCandidateAttestation 两条;内容(112 条可选)未变。
2026-11-02 11:02:52 +08:00
姚章浩 770252b55a test(candidate): 空目标候选夹具与网关审计续签
父要在真实浏览器里点"签发授权 → 启动同步",需要一个目标确实为空、来源确实存在
的候选环境。两个 test-only 入口(默认 skip,已登记 scripts/test-scope.json 的可选范围):

- TestCandidateEmptyTargetFixture:用已有 DS02/r38 夹具装配十表全非空的独立 fullshape
  测试来源并封存,登记为 test_sealed;同时用产品自己的 ScopeContentEmpty 证明目标范围
  为空(KB/tenant 骨架在、十表内容 0 行),并另建同 mapping 的运行期索引(夹具索引会在
  测试结束时删除)。目标指纹用夹具真实桶名重算,保证与 API/worker 同一份 env 一致。
- TestCandidateAttestation:签发/续签 object-gateway 审计(有效期上限 1 小时),
  四个证据摘要绑真实文件(写入者清单、只读策略、reader 拒写探测、真实排空记录)。

不改产品代码、不放宽任何准入;来源明确标 fixture-assembled-legal-fullshape,
不冒称原 C01 两份封存或 Capture/Export HTTP。
2026-11-02 11:02:38 +08:00
姚章浩 c256f2985c fix(web): 空/非空/不可读目标分开呈现并收窄建基线入口
build / build (push) Successful in 1m42s
后端 overview 早已区分 known / unknown+empty_baseline / unknown 非空 / unreachable,
页面只判断 state === 'known',把"已证空"和"读不到"都写成"未知",并在两种情况下
都给出"建立目标权威基线"入口——而该接口对空目标返回 409
library_baseline_target_empty、对读不到的目标返回 503 library_baseline_unprobeable。
按后端真实契约做最小分类:

- LibraryKnown 补 empty_baseline 与 unreachable 两个已存在的字段/状态;
- 页面按四类分别呈现:known 显示生效版本+快照摘要;空目标写明"已读且为空/首次发布/
  写前仍生成完整预备份";非空缺权威版本才指向基线入口;不可读照实说读取失败;
- baselineNeeded 从"非 known"收窄为"非空且无权威版本",不动授权、写闸、预备份、
  容量与 worker 执行准入等任何后端检查,也不放宽后端的 409。

行为测试新增四类状态、空→不可读→空刷新不残留旧状态、空目标无候选时按钮禁用;
原"非 known 一律未知"的断言随文案更正。本地门禁 16 项 success
(tests-web 498 passed,tsc -b 无错)。
2026-11-02 10:56:29 +08:00
姚章浩 f1cddfb601 test(r39): 重放前后逐对象实取字节摘要比对
build / build (push) Successful in 1m55s
原重放段只比 Listing 的 key@versionId#etag#size,同一版本号下的字节
被改写它看不见,因此"重放后字节不变"缺实测证据。

改为重放前后各调一次 r38ObjectVersionDigests:逐 key+versionId 真实
GET 并本地算 SHA256,落完整版本清单(含版本、ETag、size、摘要),
再逐项比较;GET 失败、清单缺条目、版本增减或摘要不同都判失败。
治理 PG/ES/ledger/receipt 零新增断言保持原样。

离线负控 TestR39ReplayObjectByteControl:固定"旧判据对字节改写报相同"
的盲区,并要求新判据在同一元数据上报 bytes changed、正例不误报、
空清单/缺摘要必须红;用变异(去掉摘要比较)确认该负控会红后恢复。

台账:04-baseline-and-auth.json 分开记录授权 201 与基线 202,
不再让后一次状态码覆盖前一次。
2026-11-02 10:14:00 +08:00
姚章浩 d2ff089b95 ci(checkout): 补取事件 before 失败时重试一次并保留错误
build / build (push) Successful in 1m27s
浅克隆里用 fetch --depth 取一个未公开的对象(改写推送后的旧提交就是这种)时,
git 会在这次 fetch 中途重写 .git/shallow,随后中止:
fatal: shallow file has changed since we read it。
before 取不到 ⇒ commits 检查算不出范围 ⇒ 整条流水线红,红的原因与代码质量无关。

证据(真实 CI):run 305–317 共 13 次(含 317 的原地重跑)全部停在这一步;
同一仓库里紧接着重试同一条命令即成功。用同一 runner 镜像在容器里跑
"bootstrap + ci_checkout.sh --post-checkout"(真实事件负载)复现出该 fatal,
原脚本报"取不到事件 before",改后报"已补取事件 before eaf389e506f5",
随后 ci_gate 算出 事件 Git 范围 eaf389e..12b3769|1 条提交 并通过。

只加一次重试与错误回显:范围选择、判红口径、fail-closed 语义都没动 ——
两次都失败仍明确报警,由 commits 检查判红,不收缩范围、不放行。
2026-11-02 09:25:06 +08:00
姚章浩 12b37690ec test(r39): 真实HTTP发布成功闭环与同请求幂等
build / build (push) Failing after 10s
新增单场景用例 TestFormalPublishHTTPReplaySucceededR39:发布作业只来自
真实监听的 POST /api/v1/library/syncs(管理员会话+真实授权),不再用
store.CreateLibraryDispatch 或 httptest 冒充。链路覆盖 handoff 队列
(capture 角色)→ 真实 libraryctl pack/unpack/verify → 独立 executor
archive → release-executor 队列 → succeeded/production_effective/维护释放
/入口恢复,以及同一 body 原样重放的零新增执行。

来源身份先声明后封存:source_id 由独立配置推导,来源行真实落进隔离 PG
schema 再读回后封存;目标基线走正式 POST /api/v1/library/baselines 的
真实整库捕获,不手写生效版本。旧 r38 故障恢复分支一行未改。
2026-11-02 09:02:12 +08:00
13 changed files with 3210 additions and 20 deletions
+17 -3
View File
@@ -108,9 +108,23 @@ if [ -n "$BEFORE" ] && [ "$BEFORE" != "$ZERO" ]; then
if git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then
echo "✅ 事件 before ${BEFORE:0:12} 已在克隆里(本次推送范围可直接算)"
else
gitauth fetch -q --depth 50 origin "$BEFORE" 2>/dev/null \
&& echo "✅ 已补取事件 before ${BEFORE:0:12}" \
|| echo "⚠️ 取不到事件 before ${BEFORE:0:12}:范围校验会明确失败,不会静默收缩"
# ⚠️ 为什么重试一次:浅克隆里用 `fetch --depth` 取一个**未公开**的对象(撤销/改写推送之后
# 的旧提交就是这种)时,git 会在这次 fetch 中途重写 .git/shallow,随后发现自己读到的
# 与开始时不同就整体中止:`fatal: shallow file has changed since we read it`。
# 实测(run 305–317,含原地重跑)这一步必中止,而**同一仓库里紧接着重试同一条命令即成功**。
# before 取不到 ⇒ commits 检查算不出范围 ⇒ 流水线红,且红的原因与代码质量无关。
# 重试只负责"把 before 取进克隆",不收缩范围;两次都失败仍明确报警,由 commits 检查判红。
FETCH_OK=""
FETCH_ERR=""
for _attempt in 1 2; do
FETCH_ERR="$(gitauth fetch -q --depth 50 origin "$BEFORE" 2>&1 >/dev/null || true)"
if git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then FETCH_OK=1; break; fi
done
if [ -n "$FETCH_OK" ]; then
echo "✅ 已补取事件 before ${BEFORE:0:12}"
else
echo "⚠️ 取不到事件 before ${BEFORE:0:12}:范围校验会明确失败,不会静默收缩(最后一次错误:${FETCH_ERR:0:160})"
fi
fi
else
echo "(事件里没有 before / before 为全 0:新分支推送,范围按默认分支为基准判)"
+32
View File
@@ -1,5 +1,37 @@
# Changelog
## 开发快照(发布管理:空目标观测与基线入口)
### Fixed
- 发布管理:还没有读到生产目标状态时(首次加载、整库状态读取失败、当前范围没有数据)
不再把本地默认值当成"目标非空",也不再提供会返回 409/503 的"建立目标权威基线"入口;
有效四类响应(有权威版本 / 已读且为空 / 非空无版本 / 不可读)的呈现与入口保持不变。
- 发布管理:刷新失败但仍保留上一次目标观测时,页面标明"上次观测 · 刷新失败,可能已过期",
不把旧结果当成刚刚验证过的状态。
## 开发快照(发布 HTTP 成功闭环:真实监听 POST /library/syncs → handoff → executor → 幂等重放)
### Added
- 新增单一场景用例 `TestFormalPublishHTTPReplaySucceededR39`(`KG_R38_SCENARIO=publish-http`):
发布作业只来自真实监听的 `POST /api/v1/library/syncs`(管理员会话 + 真实授权),
不调用 `store.CreateLibraryDispatch` 冒充 HTTP,也不再走 httptest。
- 新增 `r39` 证据链:来源身份先声明后封存(独立推导 `source_id`,来源行真实落进隔离
PostgreSQL schema 再读回)、目标基线走正式 `POST /api/v1/library/baselines`(真实整库
捕获 + 校验 + `target_baseline` 证据,不手写生效版本)、handoff 用真实 `local` transport
与真实 `libraryctl` 的 `snapshot-pack/unpack/verify`、发布后逐表/引用/索引向量/对象版本
GET SHA 的独立 oracle,以及对同一 body 原样重放(`reused=true`,治理行/目标内容/索引/
对象版本逐字节不变)。
- 新增外置运行器 `publish_http_runner.py`:沿用已审 `r38_runner.py` 的"创建前登记 +
创建后补不可变 ID"与四次源码门禁,清理仍交给已审 `r38_cleanup.py`(run 目录名以
`r38-` 开头),不重跑旧 runner、不改它的证据。
### Changed
- 本批次只新增一个单场景测试与外置 wrapper,旧 r38 故障恢复分支(含 `:775` 的测试直建
dispatch)一行未改,仍按原语义保留。
## 开发快照(DS03 早段 PG 恢复:合法来源 + database 23505 → held → 同窗口恢复)
### Added
@@ -0,0 +1,366 @@
package v071
// 候选页面(空目标首次发布)的夹具准备:只在显式 opt-in 时运行,产物留在运行器登记的
// 隔离资源里,供父在真实浏览器里点"签发授权 → 启动同步"。
//
// 与已审 r39 publish-http 的区别(必须写清,避免把两件事混成一件):
// - r39 证明的是"正式 publish HTTP + 同请求幂等",它的目标是非空并先建了基线;
// - 这里只准备一个**空**目标(KB 行存在、十表内容为 0)与一份**独立 fullshape 测试来源**,
// 来源快照由本夹具装配并封存,明确标 fixture,不冒称原 C01 两份封存的云发布;
// - 不做任何发布:写入必须由父在页面上触发,交付前目标保持为空。
import (
"bytes"
"context"
"crypto/ed25519"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"testing"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"knowledge-governance/internal/appconfig"
"knowledge-governance/internal/governance"
"knowledge-governance/internal/objectgateway"
postgresstore "knowledge-governance/internal/platform/postgres"
)
func TestCandidateEmptyTargetFixture(t *testing.T) {
if os.Getenv("KG_UI_CANDIDATE_PREP") != "1" {
t.Skip("requires explicit candidate fixture opt-in (KG_UI_CANDIDATE_PREP=1)")
}
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Minute)
defer cancel()
out := os.Getenv("KG_UI_PREP_OUT")
if out == "" || !filepath.IsAbs(out) {
t.Fatal("KG_UI_PREP_OUT must be an absolute path")
}
sourceArchive := os.Getenv("KG_UI_SOURCE_ARCHIVE")
govDSN := os.Getenv("KG_UI_GOVERNANCE_DSN")
runtimeIndex := os.Getenv("KG_UI_RUNTIME_INDEX")
if sourceArchive == "" || govDSN == "" || runtimeIndex == "" {
t.Fatal("KG_UI_SOURCE_ARCHIVE / KG_UI_GOVERNANCE_DSN / KG_UI_RUNTIME_INDEX are required")
}
// 目标指纹必须与 API/release worker 用同一份 env 推导(运行器给三者同一份 env 文件)。
// 它只绑数据身份(库/租户/知识库/索引/桶),换端点不改指纹。
// 桶名由夹具分配(reset 时会生成唯一桶),所以指纹必须用夹具真实桶名计算,
// 运行器随后把它写进 API/worker 的 env —— 两边因此得到同一个指纹。
fx := openWriteSetFixtureWith(t, writeSetFixtureOptions{reset: true, archiveRoot: sourceArchive, embeddingDimension: 1024})
if err := os.Setenv("LIBRARY_PRODUCTION_S3_BUCKET", fx.bucket); err != nil {
t.Fatal(err)
}
cfg, err := appconfig.Load(os.Getenv)
if err != nil {
t.Fatalf("load product config from candidate env: %v", err)
}
fingerprint := cfg.LibraryTargetIdentity()
if fingerprint == "" {
t.Fatal("candidate target fingerprint is empty: product config is incomplete")
}
// 适配层解析 embedding 模型/向量库需要这两列与两张表(与 r39 fullshape 同一装配)。
if _, err := fx.pool.Exec(ctx, `ALTER TABLE knowledge_bases ADD COLUMN embedding_model_id text, ADD COLUMN vector_store_id text;
CREATE TABLE models(id text PRIMARY KEY,name text,parameters jsonb,deleted_at timestamptz);
CREATE TABLE vector_stores(id text PRIMARY KEY,engine_type text,index_config jsonb,deleted_at timestamptz);
INSERT INTO models VALUES('candidate-model','text-embedding-v4','{"embedding_parameters":{"dimension":1024}}',NULL);
INSERT INTO vector_stores VALUES('candidate-vector','elasticsearch_v8','{}',NULL);
UPDATE knowledge_bases SET embedding_model_id='candidate-model',vector_store_id='candidate-vector';`); err != nil {
t.Fatalf("seed embedding model/vector store: %v", err)
}
// 空目标的第一条证据:产品自己的范围探针必须说"空",且逐表计数为 0。
empty, counts, err := ScopeContentEmpty(ctx, fx.pool, ProductionScope{TenantID: wsTargetTenant, KnowledgeBaseID: wsTargetKB})
if err != nil {
t.Fatalf("product scope probe on empty target: %v", err)
}
if !empty {
t.Fatalf("candidate target scope is not empty: %v", counts)
}
for table, n := range counts {
if n != 0 {
t.Fatalf("candidate target %s has %d rows in scope; the target must be empty before delivery", table, n)
}
}
var kbRows int64
if err := fx.pool.QueryRow(ctx, `SELECT count(*) FROM knowledge_bases WHERE id=$1 AND tenant_id=$2`, wsTargetKB, wsTargetTenant).Scan(&kbRows); err != nil {
t.Fatal(err)
}
if kbRows != 1 {
t.Fatalf("empty target still needs its KB skeleton: knowledge_bases rows=%d", kbRows)
}
mapping, err := fx.target(t).Search().MappingSHA256(ctx)
if err != nil {
t.Fatalf("read fixture index mapping digest: %v", err)
}
// 独立 fullshape 测试来源:声明行 → 真实 PG schema 落库并读回 → 用读回封存。
spec := r38LegalSourceSpec(t, fx)
readback, sourceStoreEvidence := r39SeedSourceStore(t, fx, spec)
source := r38SealFullShape(t, fx, ds02Spec{rows: readback, vectors: ds02Vectors, objects: ds02Objects}, mapping)
parts := r38SourcePartCounts(t, fx, source)
if len(parts) != len(ds02TargetTables) {
t.Fatalf("sealed source has %d table parts, want %d", len(parts), len(ds02TargetTables))
}
// 运行期索引:夹具索引会在测试结束时被删;用同一份 mapping 另建索引,
// 让封存里的 index_mapping_sha256 与运行期目标索引仍逐字节一致。
mappingBody, err := candidateIndexMapping(ctx, fx.searchBase, fx.index)
if err != nil {
t.Fatalf("read fixture index mapping: %v", err)
}
if err := candidateCreateIndex(ctx, fx.searchBase, runtimeIndex, mappingBody); err != nil {
t.Fatalf("create candidate runtime index: %v", err)
}
exporter, err := NewSearchExporter(SearchExporterConfig{BaseURL: fx.searchBase, Index: runtimeIndex})
if err != nil {
t.Fatal(err)
}
runtimeMapping, err := exporter.MappingSHA256(ctx)
if err != nil {
t.Fatalf("read runtime index mapping digest: %v", err)
}
if runtimeMapping != mapping {
t.Fatalf("runtime index mapping %s != sealed %s", runtimeMapping, mapping)
}
// 一次性治理库:迁移 + 网关 schema,再登记这份 test_sealed 版本。
gov, err := pgxpool.New(ctx, govDSN)
if err != nil {
t.Fatal(err)
}
defer gov.Close()
if err := postgresstore.ApplyMigrations(ctx, gov); err != nil {
t.Fatalf("apply governance migrations: %v", err)
}
if _, err := gov.Exec(ctx, objectgateway.Schema); err != nil {
t.Fatalf("apply object gateway schema: %v", err)
}
store := postgresstore.NewStore(gov).WithLibraryTarget(fingerprint)
sourceIdentity := governance.LibrarySourceIdentity{Project: os.Getenv("LIBRARY_SOURCE_PROJECT"),
App: os.Getenv("LIBRARY_SOURCE_APP"), Scope: "internal",
TenantID: wsSourceTenant, KnowledgeBaseID: wsSourceKB}
sourceID, err := sourceIdentity.ID()
if err != nil {
t.Fatal(err)
}
versionID := governance.LibraryVersionID(governance.LibraryVersionTestSealed, "internal", source)
if err := store.RecordLibraryVersion(ctx, governance.LibraryVersion{ID: versionID, Scope: "internal",
Kind: governance.LibraryVersionTestSealed, SnapshotID: source,
Evidence: map[string]any{
"source_id": sourceID, "source_identity": sourceIdentity,
"capture_job": "candidate-empty-target-fixture",
"assembly": map[string]any{"kind": "fixture-assembled-legal-fullshape",
"source_store": "r39_source schema in the isolated candidate business DB",
"note": "候选页面的独立 fullshape 测试夹具;不是 WeKnora agent 捕获,也不是原 C01 两份封存,不声称 Capture/Export HTTP 已验收",
"manifest_sha256": source, "source_parts_row_counts": parts},
},
CreatedBy: "admin", CreatedAt: time.Now().UTC()}); err != nil {
t.Fatalf("register candidate test_sealed version: %v", err)
}
candidate, found, err := store.LibrarySourceCandidate(ctx, "internal", sourceID)
if err != nil {
t.Fatal(err)
}
if !found || candidate.SnapshotID != source {
t.Fatalf("registered candidate is not readable back by source_id: found=%v snapshot=%v", found, candidate.SnapshotID)
}
// 网关的只读权威角色:角色名/口令由运行器生成并登记所有权,这里只负责创建与授权。
// 授权范围与网关真正读取的表一致(jobs / library_*),不给写权限。
readerRole, readerPassword := os.Getenv("KG_UI_READER_ROLE"), os.Getenv("KG_UI_READER_PASSWORD")
readerDSNFile := os.Getenv("KG_UI_READER_DSN_FILE")
if readerRole == "" || readerPassword == "" || !filepath.IsAbs(readerDSNFile) {
t.Fatal("KG_UI_READER_ROLE / KG_UI_READER_PASSWORD / KG_UI_READER_DSN_FILE are required")
}
quotedRole := pgx.Identifier{readerRole}.Sanitize()
if _, err := gov.Exec(ctx, "CREATE ROLE "+quotedRole+" LOGIN PASSWORD '"+readerPassword+"'"); err != nil {
t.Fatalf("create gateway reader role: %v", err)
}
if _, err := gov.Exec(ctx, "GRANT USAGE ON SCHEMA public TO "+quotedRole); err != nil {
t.Fatalf("grant reader schema usage: %v", err)
}
if _, err := gov.Exec(ctx, "GRANT SELECT ON jobs,library_jobs,library_operations,library_attempts,library_versions TO "+quotedRole); err != nil {
t.Fatalf("grant reader table select: %v", err)
}
readerURL, err := url.Parse(govDSN)
if err != nil {
t.Fatal(err)
}
readerURL.User = url.UserPassword(readerRole, readerPassword)
if err := os.WriteFile(readerDSNFile, []byte(readerURL.String()), 0o600); err != nil {
t.Fatalf("write reader DSN file: %v", err)
}
report := map[string]any{
"generated_at": time.Now().UTC().Format(time.RFC3339Nano),
"kind": "candidate-empty-target-fixture",
"target_fingerprint": fingerprint,
"target": map[string]any{"tenant_id": wsTargetTenant, "knowledge_base_id": wsTargetKB,
"database": os.Getenv("KG_LIBRARY_TEST_DATABASE_URL") != "", "empty_probe_counts": counts,
"knowledge_bases_rows": kbRows,
"note": "KB 骨架存在、十表内容为 0:这是首次发布的空目标,不是'没有目标'"},
"runtime_index": runtimeIndex,
"mapping_sha256": mapping,
"runtime_mapping_sha256": runtimeMapping,
"bucket": fx.bucket,
"s3_endpoint": os.Getenv("KG_LIBRARY_TEST_S3_ENDPOINT"),
"source": map[string]any{"snapshot": source, "source_id": sourceID,
"project": sourceIdentity.Project, "app": sourceIdentity.App,
"tenant_id": sourceIdentity.TenantID, "knowledge_base_id": sourceIdentity.KnowledgeBaseID,
"parts": parts, "assembly": "fixture-assembled-legal-fullshape",
"source_store": sourceStoreEvidence},
"version": map[string]any{"id": versionID, "kind": string(governance.LibraryVersionTestSealed),
"snapshot": source, "created_by": "admin"},
}
raw, err := json.MarshalIndent(report, "", " ")
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(out), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(out, raw, 0o600); err != nil {
t.Fatal(err)
}
t.Logf("candidate fixture ready: snapshot=%s source_id=%s version=%s", source, sourceID, versionID)
}
func TestCandidateAttestation(t *testing.T) {
if os.Getenv("KG_UI_ATTESTATION") != "1" {
t.Skip("requires explicit candidate attestation opt-in (KG_UI_ATTESTATION=1)")
}
// 网关每次请求都重读审计文件,所以刷新只需原子替换该文件:不用重启网关,
// 也不会与正在进行的发布互相干扰。有效期上限是 1 小时(objectgateway.AuditVerifier)。
seedRaw, err := hex.DecodeString(os.Getenv("KG_UI_ATTESTATION_SEED"))
if err != nil || len(seedRaw) != ed25519.SeedSize {
t.Fatal("KG_UI_ATTESTATION_SEED must be a 32-byte hex seed")
}
path := os.Getenv("KG_UI_AUDIT_FILE")
pubPath := os.Getenv("KG_UI_ATTESTATION_PUB_FILE")
if !filepath.IsAbs(path) || !filepath.IsAbs(pubPath) {
t.Fatal("KG_UI_AUDIT_FILE and KG_UI_ATTESTATION_PUB_FILE must be absolute paths")
}
evidence := map[string]string{}
for key, env := range map[string]string{
"writer_inventory_sha256": "KG_UI_EVIDENCE_WRITER",
"policy_evidence_sha256": "KG_UI_EVIDENCE_POLICY",
"denial_evidence_sha256": "KG_UI_EVIDENCE_DENIAL",
"drain_evidence_sha256": "KG_UI_EVIDENCE_DRAIN",
} {
value := os.Getenv(env)
if len(value) != 64 {
t.Fatalf("%s must be a 64-hex digest of a real evidence file", env)
}
evidence[key] = value
}
scope := objectgateway.Digest([]byte(os.Getenv("OBJECT_GATEWAY_PROVIDER") + "\x00" +
os.Getenv("OBJECT_GATEWAY_ENDPOINT") + "\x00" + os.Getenv("OBJECT_GATEWAY_BUCKET")))
now := time.Now().UTC()
key := ed25519.NewKeyFromSeed(seedRaw)
payload, err := json.Marshal(objectgateway.Attestation{Protocol: objectgateway.Protocol, Scope: scope,
GatewayID: os.Getenv("OBJECT_GATEWAY_ID"), IssuedAt: now.Add(-2 * time.Second), ExpiresAt: now.Add(45 * time.Minute),
WriterInventory: evidence["writer_inventory_sha256"], PolicyEvidence: evidence["policy_evidence_sha256"],
DenialEvidence: evidence["denial_evidence_sha256"], DrainEvidence: evidence["drain_evidence_sha256"]})
if err != nil {
t.Fatal(err)
}
signed, err := json.Marshal(objectgateway.SignedAttestation{Payload: payload, Signature: ed25519.Sign(key, payload)})
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
temp := path + ".new"
if err := os.WriteFile(temp, signed, 0o600); err != nil {
t.Fatal(err)
}
if err := os.Chmod(temp, 0o600); err != nil {
t.Fatal(err)
}
if err := os.Rename(temp, path); err != nil {
t.Fatal(err)
}
public := base64.StdEncoding.EncodeToString(key.Public().(ed25519.PublicKey))
if err := os.WriteFile(pubPath, []byte(public), 0o600); err != nil {
t.Fatalf("write gateway audit public key: %v", err)
}
t.Logf("candidate attestation refreshed: scope=%s gateway=%s expires=%s", scope, os.Getenv("OBJECT_GATEWAY_ID"), now.Add(45*time.Minute).Format(time.RFC3339))
}
func candidateIndexMapping(ctx context.Context, base, index string) (map[string]any, error) {
response, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/"+index+"/_mapping", nil)
if err != nil {
return nil, err
}
client := &http.Client{Timeout: 20 * time.Second}
res, err := client.Do(response)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("read mapping %s: HTTP %d", index, res.StatusCode)
}
raw, err := io.ReadAll(io.LimitReader(res.Body, 4<<20))
if err != nil {
return nil, err
}
var payload map[string]json.RawMessage
if err := json.Unmarshal(raw, &payload); err != nil {
return nil, err
}
mappings, ok := payload[index]
if !ok {
return nil, fmt.Errorf("mapping response does not contain index %s", index)
}
var value map[string]any
if err := json.Unmarshal(mappings, &value); err != nil {
return nil, err
}
return value, nil
}
func candidateCreateIndex(ctx context.Context, base, index string, body map[string]any) error {
client := &http.Client{Timeout: 20 * time.Second}
check, err := http.NewRequestWithContext(ctx, http.MethodHead, base+"/"+index, nil)
if err != nil {
return err
}
if res, err := client.Do(check); err == nil {
res.Body.Close()
if res.StatusCode == http.StatusOK {
return fmt.Errorf("runtime index %s already exists: refusing to reuse an unknown index", index)
}
}
payload, err := json.Marshal(body)
if err != nil {
return err
}
request, err := http.NewRequestWithContext(ctx, http.MethodPut, base+"/"+index, bytes.NewReader(payload))
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
res, err := client.Do(request)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
detail, _ := io.ReadAll(io.LimitReader(res.Body, 4096))
return fmt.Errorf("create runtime index %s: HTTP %d %s", index, res.StatusCode, detail)
}
return nil
}
@@ -0,0 +1,649 @@
package v071
// r39 辅助函数:只服务 publish-http 单场景,复用旧分支已审的 fixture/读回/oracle 规则。
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/jackc/pgx/v5/pgxpool"
"knowledge-governance/internal/governance"
"knowledge-governance/internal/librarysnapshot"
postgresstore "knowledge-governance/internal/platform/postgres"
)
// r39BaseEnv 是 API / capture / release 三份进程共用的产品配置。
// 唯一差异(归档根、角色、worker id)在调用点显式覆盖,避免"两套 env 慢慢分叉"。
func r39BaseEnv(t *testing.T, fx *writeSetFixture) map[string]string {
t.Helper()
sourceArchive := os.Getenv("KG_R38_SOURCE_ARCHIVE")
return map[string]string{
"KG_LIBRARY_TEST_ISOLATED": "1",
"DATABASE_URL": os.Getenv("KG_R38_GOVERNANCE_DSN"),
"LIBRARY_WORKER_ROLE": "release-executor",
"WORKER_POLL_INTERVAL": "200ms", "WORKER_LEASE_DURATION": "30s",
"PRODUCTION_RELEASE_REQUESTS_ENABLED": "true", "PRODUCTION_BACKUP_RESTORE_VERIFIED": "true",
"LIBRARY_ARCHIVE_ROOT": sourceArchive,
"LIBRARY_CAPACITY_BUDGET_BYTES": "1073741824",
"LIBRARY_PRODUCTION_DATABASE_URL": os.Getenv("KG_LIBRARY_TEST_DATABASE_URL"),
"LIBRARY_PRODUCTION_ELASTIC_URL": "",
"LIBRARY_PRODUCTION_INDEX": fx.index,
"LIBRARY_PRODUCTION_TENANT_ID": "10001",
"LIBRARY_PRODUCTION_KB_INTERNAL": wsTargetKB,
"LIBRARY_PRODUCTION_S3_PROVIDER": "minio",
"LIBRARY_PRODUCTION_S3_ENDPOINT": os.Getenv("KG_LIBRARY_TEST_S3_ENDPOINT"),
"LIBRARY_PRODUCTION_S3_BUCKET": fx.bucket,
"LIBRARY_PRODUCTION_S3_ACCESS_KEY": os.Getenv("KG_R38_READER_KEY"),
"LIBRARY_PRODUCTION_S3_SECRET_KEY": os.Getenv("KG_R38_READER_SECRET"),
"LIBRARY_TARGET_INSTANCE": os.Getenv("KG_R38_PROJECT"),
"LIBRARY_TARGET_PROJECT": os.Getenv("KG_R38_PROJECT"),
"LIBRARY_TARGET_WRITERS": "writer",
"LIBRARY_TARGET_NON_WRITERS": "ingress=entry gate,redis=queue,minio=object backend",
"LIBRARY_TARGET_INGRESS_URL": os.Getenv("KG_R38_INGRESS"),
"LIBRARY_TARGET_INGRESS_SERVICE": "ingress",
"LIBRARY_TARGET_INGRESS_DRAIN_FILE": filepath.Join(os.Getenv("KG_R38_RUN"), "gate/drain.json"),
"LIBRARY_TARGET_REDIS_ADDR": os.Getenv("KG_R38_REDIS_ADDR"),
"LIBRARY_TARGET_REDIS_PASSWORD": os.Getenv("KG_R38_REDIS_PASSWORD"),
"LIBRARY_TARGET_REDIS_DB": "0",
"LIBRARY_TARGET_JOURNAL_ROOT": filepath.Join(os.Getenv("KG_R38_RUN"), "maintenance"),
"LIBRARY_TARGET_DOCKER_SOCKET": "/var/run/docker.sock",
"LIBRARY_EMBEDDING_DIMENSION": "1024",
"LIBRARY_SOURCE_INSTANCE": os.Getenv("KG_R38_SOURCE_INSTANCE"),
"LIBRARY_SOURCE_PROJECT": os.Getenv("KG_R38_SOURCE_PROJECT"),
"LIBRARY_SOURCE_APP": os.Getenv("KG_R38_SOURCE_APP"),
"LIBRARY_SOURCE_TENANT_ID": "10002",
"LIBRARY_SOURCE_KB_INTERNAL": wsSourceKB,
}
}
func r39ReadJSON(t *testing.T, path string) map[string]any {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
var out map[string]any
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("decode %s: %v", path, err)
}
return out
}
func r39DatabaseName(t *testing.T, dsn string) string {
t.Helper()
parsed, err := pgxpool.ParseConfig(dsn)
if err != nil {
t.Fatalf("parse production dsn: %v", err)
}
return parsed.ConnConfig.Database
}
func r39RegisteredDatabase(t *testing.T, state map[string]any, name string) map[string]any {
t.Helper()
resources, _ := state["resources"].([]any)
for _, item := range resources {
entry, _ := item.(map[string]any)
if entry["kind"] == "database" && entry["name"] == name {
return entry
}
}
t.Fatalf("database %s was never registered before creation", name)
return nil
}
func r39DatabaseOID(t *testing.T, state map[string]any, name string) string {
t.Helper()
oid, _ := r39RegisteredDatabase(t, state, name)["id"].(string)
return oid
}
func r39DatabaseOwner(t *testing.T, state map[string]any, name string) string {
t.Helper()
owner, _ := r39RegisteredDatabase(t, state, name)["owner"].(string)
return owner
}
// r39ArchiveListing 枚举归档本体(清单 + 被引用字节),用于证明 executor 起始为空。
func r39ArchiveListing(t *testing.T, root string) map[string]any {
t.Helper()
versions := []string{}
blobs := []string{}
if entries, err := os.ReadDir(filepath.Join(root, "versions")); err == nil {
for _, entry := range entries {
versions = append(versions, entry.Name())
}
}
if entries, err := os.ReadDir(filepath.Join(root, "blobs")); err == nil {
for _, entry := range entries {
blobs = append(blobs, entry.Name())
}
}
sort.Strings(versions)
sort.Strings(blobs)
return map[string]any{"root": root, "versions": versions, "blobs": blobs,
"versions_present": len(versions) > 0, "blob_count": len(blobs)}
}
func r39ArchiveHas(t *testing.T, root, id string) bool {
t.Helper()
_, err := os.Stat(filepath.Join(root, "versions", id+".json"))
return err == nil
}
func r39Manifest(t *testing.T, root, id string) (librarysnapshot.Manifest, []byte) {
t.Helper()
archive, err := librarysnapshot.Open(root, 256<<20)
if err != nil {
t.Fatalf("open archive %s: %v", root, err)
}
defer archive.Close()
manifest, err := archive.Load(id)
if err != nil {
t.Fatalf("load snapshot %s from %s: %v", id, root, err)
}
raw, err := os.ReadFile(filepath.Join(root, "versions", id+".json"))
if err != nil {
t.Fatalf("read manifest %s: %v", id, err)
}
return manifest, raw
}
// r39WaitLibraryJob 等到业务作业进入期望终态;中途的失败/取消立即报错,不吞。
func r39WaitLibraryJob(t *testing.T, store *postgresstore.Store, id string, want governance.LibraryJobState, seconds int) governance.LibraryJob {
t.Helper()
deadline := time.Now().Add(time.Duration(seconds) * time.Second)
var last governance.LibraryJob
for time.Now().Before(deadline) {
job, err := store.LibraryJob(context.Background(), id)
if err != nil {
t.Fatalf("read library job %s: %v", id, err)
}
last = job
if job.State == want {
return job
}
if job.State == governance.LibraryJobFailed || job.State == governance.LibraryJobCancelled {
t.Fatalf("library job %s reached %s stage=%s error=%s recovery=%s", id, job.State, job.Stage, job.Error, job.Recovery)
}
time.Sleep(250 * time.Millisecond)
}
t.Fatalf("library job %s timeout in state %s stage=%s error=%s", id, last.State, last.Stage, last.Error)
return last
}
func r39WaitQueue(t *testing.T, pool *pgxpool.Pool, id, want string, seconds int) string {
t.Helper()
deadline := time.Now().Add(time.Duration(seconds) * time.Second)
var state, message string
for time.Now().Before(deadline) {
if err := pool.QueryRow(context.Background(),
`SELECT state,COALESCE(last_error,'') FROM jobs WHERE id=$1`, id).Scan(&state, &message); err != nil {
t.Fatalf("read queue %s: %v", id, err)
}
if state == want {
return state
}
if state == "failed" || state == "completed" || state == "dead" {
t.Fatalf("queue %s terminal state=%s error=%s (want %s)", id, state, message, want)
}
time.Sleep(200 * time.Millisecond)
}
t.Fatalf("queue %s timeout (state=%s error=%s)", id, state, message)
return state
}
// r39FirstQueueObservation 记录队列作业第一次被看到的形态:用来证明它入的是 handoff 队列,
// 而不是"直接进 release 也行"。
func r39FirstQueueObservation(t *testing.T, pool *pgxpool.Pool, id string, seconds int) map[string]any {
t.Helper()
deadline := time.Now().Add(time.Duration(seconds) * time.Second)
for time.Now().Before(deadline) {
var raw string
err := pool.QueryRow(context.Background(), `SELECT to_jsonb(x)::text FROM jobs AS x WHERE id=$1`, id).Scan(&raw)
if err == nil {
var row map[string]any
if err := json.Unmarshal([]byte(raw), &row); err != nil {
t.Fatalf("decode queue row: %v", err)
}
return row
}
time.Sleep(100 * time.Millisecond)
}
t.Fatalf("queue job %s never appeared", id)
return nil
}
func r39EntryOpen(t *testing.T, url string) map[string]any {
t.Helper()
client := &http.Client{Timeout: 10 * time.Second}
code, detail := 0, ""
for i := 0; i < 30; i++ {
response, err := client.Get(url)
if err == nil {
raw, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
response.Body.Close()
code = response.StatusCode
detail = strings.TrimSpace(string(raw))
if code == 200 {
return map[string]any{"url": url, "http": code, "open": true, "body_prefix": truncateR39(detail, 200)}
}
} else {
detail = err.Error()
}
time.Sleep(200 * time.Millisecond)
}
return map[string]any{"url": url, "http": code, "open": false, "detail": truncateR39(detail, 300)}
}
func truncateR39(value string, limit int) string {
if len(value) <= limit {
return value
}
return value[:limit]
}
// r39RunCLI 执行真实的 libraryctl 并把原始输出(stdout/stderr/rc/字节数)留在证据里。
func r39RunCLI(t *testing.T, binary string, args ...string) map[string]any {
t.Helper()
command := exec.Command(binary, args...)
command.Env = append(os.Environ(), "LIBRARY_HANDOFF_FIXTURE=1")
var stdout, stderr strings.Builder
command.Stdout = &stdout
command.Stderr = &stderr
err := command.Run()
code := 0
if err != nil {
if exit, ok := err.(*exec.ExitError); ok {
code = exit.ExitCode()
} else {
code = -1
}
}
return map[string]any{"binary": binary, "argv": append([]string{binary}, args...), "exit_code": code,
"stdout": truncateR39(stdout.String(), 4000), "stderr": truncateR39(stderr.String(), 2000)}
}
func r39BlobDigests(t *testing.T, root string, manifest librarysnapshot.Manifest) map[string]map[string]any {
t.Helper()
out := map[string]map[string]any{}
for _, part := range manifest.Parts {
path := filepath.Join(root, "blobs", part.SHA256)
info, err := os.Stat(path)
if err != nil {
t.Fatalf("blob %s missing in %s: %v", part.SHA256, root, err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read blob %s: %v", part.SHA256, err)
}
sum := sha256.Sum256(raw)
out[part.SHA256] = map[string]any{"name": part.Name, "kind": part.Kind, "bytes": info.Size(),
"sha256": hex.EncodeToString(sum[:]), "size_matches_manifest": info.Size() == part.Bytes}
}
return out
}
// r39HandoffEvidence 证明 handoff 真的经由 libraryctl 落到独立 executor archive,
// 并用真实 pack/unpack/verify 复核两端字节(不是"复制目录后声称成功")。
func r39HandoffEvidence(t *testing.T, gov *pgxpool.Pool, store *postgresstore.Store, jobID, snapshot,
sourceArchive, executorArchive, libraryctl, repackRoot string) map[string]any {
t.Helper()
ctx := context.Background()
sourceManifest, sourceRaw := r39Manifest(t, sourceArchive, snapshot)
executorManifest, executorRaw := r39Manifest(t, executorArchive, snapshot)
sourceSum, executorSum := sha256.Sum256(sourceRaw), sha256.Sum256(executorRaw)
sourceBlobs := r39BlobDigests(t, sourceArchive, sourceManifest)
executorBlobs := r39BlobDigests(t, executorArchive, executorManifest)
matches := hex.EncodeToString(sourceSum[:]) == snapshot && hex.EncodeToString(executorSum[:]) == snapshot
matches = matches && reflectDeepEqualR39(sourceBlobs, executorBlobs) && len(sourceBlobs) == len(sourceManifest.Parts)
// 真实 libraryctl:verify(源) → pack(源) → unpack(全新归档) → verify(全新归档)。
verifySource := r39RunCLI(t, libraryctl, "snapshot-verify", "-archive", sourceArchive, "-snapshot", snapshot)
if err := os.MkdirAll(repackRoot, 0o700); err != nil {
t.Fatalf("prepare repack archive: %v", err)
}
bundleDir := filepath.Join(repackRoot, "bundle")
if err := os.MkdirAll(bundleDir, 0o700); err != nil {
t.Fatalf("prepare bundle dir: %v", err)
}
bundlePath := filepath.Join(bundleDir, snapshot+".tar")
freshArchive := filepath.Join(repackRoot, "archive")
if err := os.MkdirAll(freshArchive, 0o700); err != nil {
t.Fatalf("prepare fresh archive: %v", err)
}
pack := r39RunCLI(t, libraryctl, "snapshot-pack", "-archive", sourceArchive, "-snapshot", snapshot, "-bundle", bundlePath)
var bundleBytes int64
if info, err := os.Stat(bundlePath); err == nil {
bundleBytes = info.Size()
}
unpack := r39RunCLI(t, libraryctl, "snapshot-unpack", "-archive", freshArchive, "-bundle", bundlePath, "-snapshot", snapshot)
verifyFresh := r39RunCLI(t, libraryctl, "snapshot-verify", "-archive", freshArchive, "-snapshot", snapshot)
verifyExecutor := r39RunCLI(t, libraryctl, "snapshot-verify", "-archive", executorArchive, "-snapshot", snapshot)
freshManifest, freshRaw := r39Manifest(t, freshArchive, snapshot)
freshSum := sha256.Sum256(freshRaw)
freshBlobs := r39BlobDigests(t, freshArchive, freshManifest)
allZero := true
for _, item := range []map[string]any{verifySource, pack, unpack, verifyFresh, verifyExecutor} {
if item["exit_code"] != 0 {
allZero = false
}
}
bundleLeft, err := os.ReadDir(filepath.Join(os.Getenv("KG_R38_EXECUTOR_BUNDLE")))
bundleAbsent := err == nil && len(bundleLeft) == 0
var detail string
stage, err := store.LibraryJob(ctx, jobID)
if err == nil {
detail = stage.Stage
}
stages := ds02Read(t, &writeSetFixture{pool: gov, ctx: ctx},
`SELECT to_jsonb(x)::text FROM library_job_stages AS x WHERE job_id='`+jobID+`' AND stage='handoff'`)
return map[string]any{
"snapshot": snapshot, "job": jobID, "job_stage": detail,
"source_archive": sourceArchive, "executor_archive": executorArchive,
"source_manifest_sha256": hex.EncodeToString(sourceSum[:]), "executor_manifest_sha256": hex.EncodeToString(executorSum[:]),
"fresh_manifest_sha256": hex.EncodeToString(freshSum[:]),
"source_parts": sourceManifest.Parts, "executor_parts": executorManifest.Parts,
"source_blobs": sourceBlobs, "executor_blobs": executorBlobs,
"executor_matches_source": matches,
"fresh_matches_source": hex.EncodeToString(freshSum[:]) == snapshot && reflectDeepEqualR39(freshBlobs, sourceBlobs),
"libraryctl_all_zero": allZero,
"libraryctl": map[string]any{"verify_source": verifySource, "pack": pack, "unpack": unpack,
"verify_fresh": verifyFresh, "verify_executor": verifyExecutor},
"bundle_bytes": bundleBytes, "bundle_absent_after": bundleAbsent,
"handoff_stage_rows": stages,
"libraryctl_identity": func() map[string]any {
raw, err := os.ReadFile(libraryctl)
if err != nil {
t.Fatalf("read libraryctl: %v", err)
}
sum := sha256.Sum256(raw)
return map[string]any{"path": libraryctl, "sha256": hex.EncodeToString(sum[:])}
}(),
}
}
func reflectDeepEqualR39(a, b any) bool {
left, err := json.Marshal(a)
if err != nil {
return false
}
right, err := json.Marshal(b)
if err != nil {
return false
}
return string(left) == string(right)
}
// r39CheckPublishedIndex 用声明内容独立推导"发布后索引应有的文档":
// 目标 tenant/KB、来源 chunk 内容、来源声明的 1024 维向量,逐项核对 _source。
func r39CheckPublishedIndex(t *testing.T, spec ds02Spec, before, after map[string]map[string]any) map[string]any {
t.Helper()
vectors := r38SourceVectors()
expected := map[string]map[string]any{}
for _, chunk := range spec.rows["chunks"] {
id := fmt.Sprint(chunk["id"])
vector, ok := vectors[id]
if !ok {
t.Fatalf("source chunk %s has no declared vector", id)
}
expected[id] = map[string]any{"tenant_id": float64(wsTargetTenant), "knowledge_base_id": wsTargetKB,
"knowledge_id": chunk["knowledge_id"], "chunk_id": id, "source_id": id,
"content": chunk["content"], "embedding": vector}
}
problems := []string{}
for id, want := range expected {
doc, ok := after[id]
if !ok {
problems = append(problems, "published document missing: "+id)
continue
}
source, _ := doc["_source"].(map[string]any)
if source == nil {
problems = append(problems, "published document has no _source: "+id)
continue
}
for field, wantValue := range want {
got, exists := source[field]
if !exists {
problems = append(problems, id+"."+field+" missing")
continue
}
if field == "embedding" {
if !r39VectorEqual(wantValue.([]float64), got) {
problems = append(problems, id+".embedding differs from the declared 1024-dim vector")
}
continue
}
if fmt.Sprint(got) != fmt.Sprint(wantValue) {
problems = append(problems, fmt.Sprintf("%s.%s = %v, want %v", id, field, got, wantValue))
}
}
}
// 目标范围之外的文档必须原样保留;目标范围的旧一代文档必须被替换掉。
// 判定用独立读到的 _source(tenant/KB),不按 id 名单。
sentinels, replaced := 0, 0
for id, doc := range before {
source, _ := doc["_source"].(map[string]any)
if source != nil && fmt.Sprint(source["tenant_id"]) == fmt.Sprint(wsTargetTenant) &&
fmt.Sprint(source["knowledge_base_id"]) == wsTargetKB {
replaced++
if _, stillThere := after[id]; stillThere {
problems = append(problems, "replaced target-generation document survived: "+id)
}
continue
}
sentinels++
got, ok := after[id]
if !ok {
problems = append(problems, "sentinel document disappeared: "+id)
continue
}
if !reflectDeepEqualR39(doc["_source"], got["_source"]) {
problems = append(problems, "sentinel document changed: "+id)
}
}
if len(after) != len(expected)+sentinels {
problems = append(problems, fmt.Sprintf("index document count = %d, want %d published + %d sentinel",
len(after), len(expected), sentinels))
}
return map[string]any{"match": len(problems) == 0, "problems": problems, "published_documents": len(expected),
"sentinel_documents": sentinels, "replaced_documents": replaced, "after_documents": len(after)}
}
func r39VectorEqual(want []float64, got any) bool {
values, ok := got.([]any)
if !ok || len(values) != len(want) {
return false
}
for i, value := range values {
number, ok := value.(float64)
if !ok {
return false
}
delta := number - want[i]
if delta < 0 {
delta = -delta
}
if delta > 1e-9 {
return false
}
}
return true
}
// r39CheckPublishedObjects 逐资源按真实版本 GET 校验字节:发布后的对象必须就是来源字节。
func r39CheckPublishedObjects(t *testing.T, ctx context.Context, fx *writeSetFixture, spec ds02Spec,
expectation ds02Expectation) map[string]any {
t.Helper()
problems := []string{}
observed := map[string]any{}
ids := make([]string, 0, len(spec.objects))
for id := range spec.objects {
ids = append(ids, id)
}
sort.Strings(ids)
for _, id := range ids {
path, ok := expectation.Paths[id]
if !ok {
problems = append(problems, "no expected locator for resource "+id)
continue
}
reader, err := fx.objects.s3.Open(ctx, "minio", path)
if err != nil {
problems = append(problems, fmt.Sprintf("open published object %s: %v", id, err))
continue
}
body, readErr := io.ReadAll(reader)
reader.Close()
if readErr != nil {
problems = append(problems, fmt.Sprintf("read published object %s: %v", id, readErr))
continue
}
want := sha256.Sum256([]byte(spec.objects[id]))
got := sha256.Sum256(body)
if hex.EncodeToString(want[:]) != hex.EncodeToString(got[:]) {
problems = append(problems, fmt.Sprintf("object %s bytes differ from the source", id))
}
observed[id] = map[string]any{"locator": path, "bytes": len(body), "sha256": hex.EncodeToString(got[:]),
"matches_source": hex.EncodeToString(want[:]) == hex.EncodeToString(got[:])}
}
inventory := objectInventoryR38(t, ctx, fx)
digests := r38ObjectVersionDigests(t, ctx, fx, inventory)
return map[string]any{"match": len(problems) == 0, "problems": problems, "objects": observed,
"versions_after": len(digests), "version_digests": digests}
}
// r39ObjectVersionReadback 逐 key+versionId 实取 GET 并本地算 SHA256,
// 返回完整版本清单(含 key/versionId/ETag/size/SHA256)与同一键的摘要映射。
// GET 失败、字节数与清单不符、清单里有条目没取到摘要都直接失败(非零):
// ETag/size 只是旁证,不能代替字节。
func r39ObjectVersionReadback(t *testing.T, ctx context.Context, fx *writeSetFixture,
inventory *s3.ListObjectVersionsOutput) ([]map[string]any, map[string]string) {
t.Helper()
digests := r38ObjectVersionDigests(t, ctx, fx, inventory)
manifest := make([]map[string]any, 0, len(inventory.Versions))
for _, version := range inventory.Versions {
key, id := aws.ToString(version.Key), aws.ToString(version.VersionId)
digest, ok := digests[key+"@"+id]
if !ok || digest == "" {
t.Fatalf("version %s@%s has no measured SHA256", key, id)
}
manifest = append(manifest, map[string]any{"key": key, "version_id": id,
"etag": aws.ToString(version.ETag), "size": aws.ToInt64(version.Size), "sha256": digest})
}
sort.Slice(manifest, func(i, j int) bool {
return fmt.Sprint(manifest[i]["key"], "@", manifest[i]["version_id"]) <
fmt.Sprint(manifest[j]["key"], "@", manifest[j]["version_id"])
})
return manifest, digests
}
// r39CompareObjectVersionBytes 逐 key+versionId 比较重放前后的完整清单和实取 SHA256。
// 版本增减/消失、摘要缺失、字节不同都返回问题;空清单也算问题——
// 否则"一个对象都没读到"会被当成"完全相同"。
func r39CompareObjectVersionBytes(label string, before, after []map[string]any,
beforeDigests, afterDigests map[string]string) []string {
problems := []string{}
if len(before) == 0 || len(after) == 0 {
return []string{fmt.Sprintf("%s object version manifest is empty: %d -> %d", label, len(before), len(after))}
}
if len(before) != len(after) {
problems = append(problems, fmt.Sprintf("%s object versions %d -> %d", label, len(before), len(after)))
}
ids := func(manifest []map[string]any) []string {
out := make([]string, 0, len(manifest))
for _, row := range manifest {
out = append(out, fmt.Sprint(row["key"], "@", row["version_id"]))
}
sort.Strings(out)
return out
}
beforeIDs, afterIDs := ids(before), ids(after)
beforeSet := make(map[string]bool, len(beforeIDs))
afterSet := make(map[string]bool, len(afterIDs))
for _, id := range beforeIDs {
beforeSet[id] = true
}
for _, id := range afterIDs {
afterSet[id] = true
}
for _, id := range beforeIDs {
if !afterSet[id] {
problems = append(problems, fmt.Sprintf("%s object version disappeared: %s", label, id))
continue
}
if beforeDigests[id] == "" || afterDigests[id] == "" {
problems = append(problems, fmt.Sprintf("%s object version %s has no measured SHA256", label, id))
continue
}
if beforeDigests[id] != afterDigests[id] {
problems = append(problems, fmt.Sprintf("%s object version %s bytes changed: %s -> %s",
label, id, beforeDigests[id], afterDigests[id]))
}
}
for _, id := range afterIDs {
if !beforeSet[id] {
problems = append(problems, fmt.Sprintf("%s object version appeared: %s", label, id))
}
}
return problems
}
func r39Flatten(values map[string][]string) []string {
tables := make([]string, 0, len(values))
for table := range values {
tables = append(tables, table)
}
sort.Strings(tables)
out := make([]string, 0, 64)
for _, table := range tables {
for _, row := range values[table] {
out = append(out, table+"\x00"+row)
}
}
return out
}
func r39DiffStrings(label string, before, after []string) []string {
if len(before) != len(after) {
return []string{fmt.Sprintf("%s rows %d -> %d", label, len(before), len(after))}
}
for i := range before {
if before[i] != after[i] {
return []string{fmt.Sprintf("%s row %d changed: %s", label, i, after[i])}
}
}
return nil
}
var _ = aws.ToString
// r39PublishedScope 返回与期望同口径的实际行集合:resources/resource_bindings 按
// 目标租户取(产品按内容寻址复用同租户行),knowledge_tag_relations 没有租户列、
// 期望里已含目标范围外的关系,所以取整表;其余表按目标知识库范围。
func r39PublishedScope(t *testing.T, fx *writeSetFixture, table string, targetRows []map[string]any) []map[string]any {
t.Helper()
switch table {
case "resources", "resource_bindings":
return ds02Read(t, fx, `SELECT to_jsonb(x)::text FROM `+table+` AS x WHERE tenant_id=$1 ORDER BY 1`, wsTargetTenant)
case "knowledge_tag_relations":
return ds02Read(t, fx, `SELECT to_jsonb(x)::text FROM knowledge_tag_relations AS x ORDER BY 1`)
}
return targetRows
}
@@ -0,0 +1,75 @@
package v071
// r39 重放字节判据的离线负控:原判据只比 Listing 里的 key@versionId#etag#size,
// 同一个 versionId 的字节被改写它完全看不见。这里先把那个盲区固定下来,
// 再要求新判据逐 key+versionId 比较实取 SHA256,缺失/空清单也必须报问题。
import (
"crypto/sha256"
"encoding/hex"
"reflect"
"strings"
"testing"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
)
func r39ControlVersion() types.ObjectVersion {
return types.ObjectVersion{
Key: aws.String("kb-source/docs/1"), VersionId: aws.String("v-before"),
ETag: aws.String("\"etag-same\""), Size: aws.Int64(4)}
}
func r39ControlDigest(body string) string {
sum := sha256.Sum256([]byte(body))
return hex.EncodeToString(sum[:])
}
func r39ControlManifest(digest string) ([]map[string]any, map[string]string) {
manifest := []map[string]any{{"key": "kb-source/docs/1", "version_id": "v-before",
"etag": "\"etag-same\"", "size": int64(4), "sha256": digest}}
return manifest, map[string]string{"kb-source/docs/1@v-before": digest}
}
func TestR39ReplayObjectByteControl(t *testing.T) {
// 1) 旧判据的盲区:Listing 只有 key/versionId/etag/size,没有字节。
// 同一 versionId 的字节被改写,旧判据仍然报"完全相同"。
beforeListing := &s3.ListObjectVersionsOutput{Versions: []types.ObjectVersion{r39ControlVersion()}}
afterListing := &s3.ListObjectVersionsOutput{Versions: []types.ObjectVersion{r39ControlVersion()}}
if !reflect.DeepEqual(objectVersionIDs(beforeListing), objectVersionIDs(afterListing)) {
t.Fatalf("control setup is wrong: the legacy version-ID predicate is expected to see identical metadata")
}
beforeManifest, beforeDigests := r39ControlManifest(r39ControlDigest("abcd"))
afterManifest, afterDigests := r39ControlManifest(r39ControlDigest("abce"))
if beforeDigests["kb-source/docs/1@v-before"] == afterDigests["kb-source/docs/1@v-before"] {
t.Fatalf("control setup is wrong: the two bodies must hash differently")
}
// 2) 新判据必须在"版本元数据完全相同 + 字节不同"的输入上报出差异。
problems := r39CompareObjectVersionBytes("replay", beforeManifest, afterManifest, beforeDigests, afterDigests)
if len(problems) == 0 {
t.Fatalf("replay byte comparison missed a body change that kept key/versionId/etag/size identical")
}
if !strings.Contains(strings.Join(problems, "\n"), "bytes changed") {
t.Fatalf("byte change must be reported as bytes changed, got: %v", problems)
}
// 3) 正例对照:字节相同不得报问题。
if same := r39CompareObjectVersionBytes("replay", beforeManifest, beforeManifest, beforeDigests, beforeDigests); len(same) != 0 {
t.Fatalf("identical manifests must not report problems: %v", same)
}
// 4) 版本消失、空清单、摘要缺失都必须非零。
if got := r39CompareObjectVersionBytes("replay", beforeManifest, nil, beforeDigests, map[string]string{}); len(got) == 0 {
t.Fatalf("a disappeared/empty version manifest must be reported")
}
if got := r39CompareObjectVersionBytes("replay", nil, nil, nil, nil); len(got) == 0 {
t.Fatalf("an empty version manifest must be reported")
}
noDigest := map[string]string{}
if got := r39CompareObjectVersionBytes("replay", beforeManifest, beforeManifest, beforeDigests, noDigest); len(got) == 0 {
t.Fatalf("a version without a measured SHA256 must be reported")
}
}
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -428,6 +428,8 @@
"package": "knowledge-governance/internal/releaseadapter/v071",
"test": "TestContractIndexClaimDoesNotAlterMappingIsolated",
"reason": "真实隔离PG/ES/MinIO;验证mapping哈希不变和成功后无暂态marker,默认CI无这些服务"
}
},
{"package":"knowledge-governance/internal/releaseadapter/v071","test":"TestCandidateEmptyTargetFixture","reason":"需显式一次性本机PG/ES/MinIO与运行器注入的隔离库/归档根:为候选页面装配并封存独立 fullshape 测试来源、证空目标、登记 test_sealed;普通CI跳过不是通过"},
{"package":"knowledge-governance/internal/releaseadapter/v071","test":"TestCandidateAttestation","reason":"需运行器注入一次性 ed25519 种子与四份真实证据摘要:续签 object-gateway 审计(有效期上限1h);普通CI没有注入,跳过不是通过"}
]
}
+527
View File
@@ -17,12 +17,66 @@
"@types/react": "19.1.10",
"@types/react-dom": "19.1.7",
"@vitejs/plugin-react": "5.0.1",
"jsdom": "^30.1.3",
"prettier": "3.9.10",
"typescript": "5.9.2",
"vite": "7.3.7",
"vitest": "5.0.3"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "7.1.9",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.9.tgz",
"integrity": "sha512-vZ6JQVq8Ivgz6b/IBlAf90wZviXdluY13cQOPO+nJHbmXwGrPl2GcpqUY7a55/8ZVdfONXpCFMpRijOBCHNlcQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@csstools/css-calc": "^3.4.3",
"@csstools/css-color-parser": "^4.2.6",
"@csstools/css-parser-algorithms": "^4.0.2",
"@csstools/css-tokenizer": "^4.0.2",
"lru-cache": "^11.5.3"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}
},
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
"version": "11.5.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "10.0.5",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-10.0.5.tgz",
"integrity": "sha512-EgmJ7oEaGQECTn3H3wmnQpEy4hKY66VvM7VQndwPIQob3p877BgbyOUE3EX0LgXQCciF2H8KMdR+RznqjMNOLw==",
"dev": true,
"license": "MIT",
"dependencies": {
"bidi-js": "^1.1.0",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.5.3"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}
},
"node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
"version": "11.5.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -305,6 +359,159 @@
"node": ">=6.9.0"
}
},
"node_modules/@bramus/specificity": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.3.tgz",
"integrity": "sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==",
"dev": true,
"license": "MIT",
"dependencies": {
"css-tree": "^3.2.1"
},
"bin": {
"specificity": "bin/cli.js"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz",
"integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@csstools/css-calc": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz",
"integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.2",
"@csstools/css-tokenizer": "^4.0.2"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "4.2.7",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.7.tgz",
"integrity": "sha512-zgm9yjQz5iJB4VL8RyVwd71TWB8Q89FSxt/NzXoWqPSkEBOVtn9L6vAPtSc9hpKfB0Awo/AL2VKFHjYetBLFHQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^6.1.2",
"@csstools/css-calc": "^3.4.3"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.2",
"@csstools/css-tokenizer": "^4.0.2"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz",
"integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^4.0.2"
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.16",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.16.tgz",
"integrity": "sha512-LsDbJPP/B8cPasUJyTA5S99F7C4G93ngW8hUluXz8CFeOaaRnBPtBiIlvgcPYU0HmHNz8CXg+1vwFrIB4F6Dgw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"peerDependencies": {
"css-tree": "^3.2.1"
},
"peerDependenciesMeta": {
"css-tree": {
"optional": true
}
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz",
"integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
@@ -747,6 +954,24 @@
"node": ">=18"
}
},
"node_modules/@exodus/bytes": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz",
"integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@noble/hashes": "^1.8.0 || ^2.0.0"
},
"peerDependenciesMeta": {
"@noble/hashes": {
"optional": true
}
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1405,6 +1630,16 @@
"node": ">=6.0.0"
}
},
"node_modules/bidi-js": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
"integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
"dev": true,
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/browserslist": {
"version": "4.28.8",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
@@ -1537,12 +1772,40 @@
"dev": true,
"license": "MIT"
},
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.27.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/data-urls": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-8.0.0.tgz",
"integrity": "sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^17.1.2"
},
"engines": {
"node": "^22.14.0 || >=24.0.0"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1560,6 +1823,13 @@
}
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true,
"license": "MIT"
},
"node_modules/decode-named-character-reference": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
@@ -1602,6 +1872,19 @@
"dev": true,
"license": "ISC"
},
"node_modules/entities": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-9.0.0.tgz",
"integrity": "sha512-oJFNDJg3kTPwAD1cNn2+WmUq4wFoo09npc3Ydf1D7ED4smRiWrsKnaLxykrTWgyGEcusc87P5dxlAleol9kpkg==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-module-lexer": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
@@ -1792,6 +2075,19 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/html-encoding-sniffer": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz",
"integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.15.1"
},
"engines": {
"node": "^22.13.0 || >=24.0.0"
}
},
"node_modules/html-url-attributes": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@@ -1864,6 +2160,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"license": "MIT"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -1871,6 +2174,56 @@
"dev": true,
"license": "MIT"
},
"node_modules/jsdom": {
"version": "30.1.3",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.3.tgz",
"integrity": "sha512-hQMUPvRgN7KxsfIytDQji7bimuJeRQa6Qo5+iuiZQk5CafeRxLIA10/cG5YCBFNpD9RO3oqIiN4CO1Y44kxqXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^7.1.3",
"@asamuzakjp/dom-selector": "^10.0.3",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.15",
"@exodus/bytes": "^1.16.0",
"css-tree": "^3.2.1",
"data-urls": "^8.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^7.0.0",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.5.3",
"parse5": "^8.0.1",
"saxes": "^6.0.0",
"tough-cookie": "^6.0.2",
"undici": "^8.12.1",
"w3c-xmlserializer": "^6.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^17.2.0",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
"peerDependencies": {
"canvas": "^3.2.3"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/jsdom/node_modules/lru-cache": {
"version": "11.5.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -2209,6 +2562,13 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/micromark": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.3.tgz",
@@ -2867,6 +3227,19 @@
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
"license": "MIT"
},
"node_modules/parse5": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.1.0.tgz",
"integrity": "sha512-sq3qBFpee5jeocbFbO48VHlGr8sT7mbXS0vxlKN6sottBtJzrOG3998sr0llSZcQAGgfpdmEYAIZE0P9z0dE8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^9.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2942,6 +3315,16 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/react": {
"version": "19.1.1",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",
@@ -3066,6 +3449,16 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/rollup": {
"version": "4.63.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz",
@@ -3112,6 +3505,19 @@
"fsevents": "~2.3.2"
}
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/scheduler": {
"version": "0.26.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
@@ -3224,6 +3630,52 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tldts": {
"version": "7.4.19",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.19.tgz",
"integrity": "sha512-48dH6aSpQnpbzDUc4vIQZ1VjB8tkK+e8c1yx05/SZK+rkssgjEOlxDd/yU8/a4vGYQ9vRgJr9dwMq9TJ0b3X2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^7.4.19"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.4.20",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.20.tgz",
"integrity": "sha512-C630TWXZCZmLq0F2bW4QqwnaaxCQ/svpAqvJwnC4JvgEM5ohhBnGsEzTLSOXcN+xjz+kEaHS5uoWIw2klnqd0A==",
"dev": true,
"license": "MIT"
},
"node_modules/tough-cookie": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
"integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^7.0.5"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-7.0.0.tgz",
"integrity": "sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": "^22.14.0 || >=24.0.0"
}
},
"node_modules/trim-lines": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
@@ -3258,6 +3710,16 @@
"node": ">=14.17"
}
},
"node_modules/undici": {
"version": "8.12.3",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.12.3.tgz",
"integrity": "sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/unified": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
@@ -3562,6 +4024,54 @@
}
}
},
"node_modules/w3c-xmlserializer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz",
"integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}
},
"node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-url": {
"version": "17.3.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.3.0.tgz",
"integrity": "sha512-KwOAJrpf+GTgBvcBQJcP38sMF0DsQKdwtG+TxM2pW44P6E5k0hDbWrDmyuJDIF0Uef0aQq0ZoJs+5oI+k8ddhg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.16.0",
"tr46": "^7.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^22.14.0 || >=24.0.0"
}
},
"node_modules/why-is-node-running": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz",
@@ -3575,6 +4085,23 @@
"node": ">=20.11"
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+1
View File
@@ -21,6 +21,7 @@
"@types/react": "19.1.10",
"@types/react-dom": "19.1.7",
"@vitejs/plugin-react": "5.0.1",
"jsdom": "^30.1.3",
"prettier": "3.9.10",
"typescript": "5.9.2",
"vite": "7.3.7",
+266
View File
@@ -0,0 +1,266 @@
// @vitest-environment jsdom
//
// 同一挂载上的真实刷新/切换行为:ReleaseTargets 自己走 load() + fetch。
// 旧测试拿三次 renderToStaticMarkup 当成"刷新",那是三个互不相关的静态输入,
// 既不执行 effect 也不发请求,证明不了刷新后不残留旧状态(G1)。
// 这里只用一个挂载 + 可控 fetch,覆盖:
// - 首次读取未返回 / 请求失败 / 当前范围没有 overview:都不许说"目标非空";
// - 空 → 不可读 → 恢复(同一挂载上的真实重新读取);
// - 刷新失败保留旧观测时必须标成上次观测,恢复后清掉;
// - 对内/对外切换不串用,overview 属于别的范围时也不借用。
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import type { Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { AuthContext } from './auth'
import type { LibraryOverview } from './librarySnapshots'
import { ReleaseTargets } from './ReleaseTargets'
const adminValue = {
identity: {
id: 'u-admin',
username: 'admin',
display_name: '管理员',
role: 'admin' as const,
must_change_password: false,
},
ready: true,
login: async () => null,
logout: async () => undefined,
changePassword: async () => null,
signedOut: 0,
notifySignedOut: () => undefined,
}
type Scope = 'internal' | 'external'
type Reply = { status: number; body: unknown; hold?: Promise<void> }
const emptyTarget: LibraryOverview['production'] = {
state: 'unknown',
empty_baseline: true,
reason: '生产目标可读且为空:本次是首次发布,写入前仍会生成完整预备份',
}
const unreadableTarget: LibraryOverview['production'] = {
state: 'unreachable',
reason: '无法读取目标整库状态:请检查目标连接后重试,不能据此判定空库或建立基线',
}
const snapshot = {
snapshot_id: 'f992d64ea45bf737a0721a465b5aef2b150ef7757157eb84f3aaf8b6972d98f9',
scope: 'internal',
knowledge_base_id: '0c84a098-d3ed-4b82-a165-e6de9691d6a8',
tenant_id: 10000,
created_at: '2026-11-02T02:00:00Z',
validation_policy: 'artifact-acceptance-v1',
format: 'weknora-library-snapshot/v1',
compatibility: {
weknora_version: 'v0.7.1',
schema_version: 74,
retriever_engine: 'elasticsearch_v8',
embedding_model: 'text-embedding-v4',
embedding_dimension: 1024,
index_mapping_sha256: 'a'.repeat(64),
},
parts: [],
logical_bytes: 0,
unique_bytes: 0,
shared_bytes: 0,
totals: { documents: 0, chunks: 0, index_documents: 0, resources: 0, wiki_pages: 0 },
}
const overviewBody = (scope: string, production: LibraryOverview['production']) => ({
scope,
test_sealed: { ...snapshot, scope },
production,
prebackup: { state: 'unknown', reason: '本次目标快照还没有生产预备份' },
diff: { state: 'unknown', reason: '生产与测试的文档级差异读取未接线', added: [], updated: [], removed: [] },
jobs: [],
gate: { sync_allowed: false, reasons: ['没有与目标快照匹配的有效发布授权'] },
authorization: { state: 'none', reason: '没有与目标快照匹配的有效发布授权' },
capture_available: false,
now: '2026-11-02T02:05:00Z',
})
const snapshotsBody = {
available: true,
reason: '',
archive_root: '/tmp/archive',
snapshots: [],
versions: 0,
content_bytes: 0,
blob_file_bytes: 0,
blob_allocated_bytes: 0,
unreferenced_blobs: 0,
unreferenced_bytes: 0,
manifest_file_bytes: 0,
manifest_allocated_bytes: 0,
capture_note: '',
capture_available: false,
capture_reason: '',
}
const reply = (body: unknown, status = 200): Reply => ({ status, body })
const json = (body: unknown, status: number) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
let overviews: Record<Scope, Reply>
let container: HTMLDivElement
let root: Root
let originalFetch: typeof fetch
const mount = async () => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
await act(async () => {
root.render(
<AuthContext.Provider value={adminValue}>
<ReleaseTargets />
</AuthContext.Provider>
)
})
await settle()
}
// load() 是几层 await(fetch → json → setState);按微任务轮次推进到稳定。
const settle = async () => {
for (let round = 0; round < 6; round += 1) {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
}
}
const text = () => container.textContent ?? ''
const buttonLabels = () => Array.from(container.querySelectorAll('button')).map((node) => node.textContent ?? '')
const click = async (label: string) => {
const target = Array.from(container.querySelectorAll('button')).find((node) =>
(node.textContent ?? '').includes(label)
)
if (!target) throw new Error(`没有找到按钮:${label}(现有:${buttonLabels().join(' / ')})`)
await act(async () => {
target.dispatchEvent(new window.MouseEvent('click', { bubbles: true }))
})
await settle()
}
beforeEach(() => {
originalFetch = globalThis.fetch
overviews = {
internal: reply(overviewBody('internal', emptyTarget)),
external: reply(overviewBody('external', unreadableTarget)),
}
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url = String(input)
if (url.startsWith('/api/v1/library/snapshots')) return json(snapshotsBody, 200)
if (url.startsWith('/api/v1/library/captures')) return json({ jobs: [] }, 200)
if (url.startsWith('/api/v1/library/maintenance')) return json({}, 200)
if (url.startsWith('/api/v1/library/overview')) {
const scope = new URL(url, 'http://candidate.invalid').searchParams.get('scope')
const answer = overviews[scope === 'external' ? 'external' : 'internal']
if (answer.hold) await answer.hold
return json(answer.body, answer.status)
}
return json({ error: '测试未声明该请求' }, 404)
}) as typeof fetch
})
afterEach(async () => {
if (root) {
await act(async () => {
root.unmount()
})
}
container?.remove()
globalThis.fetch = originalFetch
})
describe('ReleaseTargets 同一挂载上的目标观测(F1/G1)', () => {
it('首次读取未返回时只说正在读取,不说目标非空', async () => {
let release: () => void = () => undefined
const hold = new Promise<void>((resolve) => {
release = resolve
})
overviews.internal = { ...reply(overviewBody('internal', emptyTarget)), hold }
await mount()
expect(text()).toContain('正在读取目标状态')
expect(text()).not.toContain('目标非空')
expect(text()).not.toContain('目标已读且为空')
release()
await settle()
expect(text()).toContain('目标已读且为空')
expect(text()).not.toContain('正在读取目标状态')
})
it('空 → 不可读 → 恢复:同一挂载按每次真实读取重新分类', async () => {
await mount()
expect(text()).toContain('目标已读且为空')
expect(text()).not.toContain('目标非空')
// 管理员 + 已封存时基线入口本来会出现,但空目标不该给(后端会 409)。
expect(buttonLabels()).not.toContain('建立目标权威基线(维护隔离)')
overviews.internal = reply(overviewBody('internal', unreadableTarget))
await click('刷新')
expect(text()).toContain('无法读取目标整库状态')
expect(text()).not.toContain('目标已读且为空')
expect(text()).not.toContain('目标非空')
expect(buttonLabels()).not.toContain('建立目标权威基线(维护隔离)')
overviews.internal = reply(overviewBody('internal', emptyTarget))
await click('刷新')
expect(text()).toContain('目标已读且为空')
expect(text()).not.toContain('无法读取目标整库状态')
expect(text()).not.toContain('目标非空')
})
it('刷新失败保留旧观测时标成上次观测,恢复读取后清除', async () => {
await mount()
expect(text()).toContain('目标已读且为空')
expect(text()).not.toContain('上次观测')
overviews.internal = reply({ error: '上游不可用' }, 502)
await click('刷新')
expect(text()).toContain('读取失败')
// 旧观测仍在,但不能冒充刚验证。
expect(text()).toContain('目标已读且为空')
expect(text()).toContain('上次观测 · 刷新失败,可能已过期')
expect(text()).not.toContain('目标非空')
overviews.internal = reply(overviewBody('internal', emptyTarget))
await click('刷新')
expect(text()).not.toContain('上次观测')
expect(text()).not.toContain('读取失败')
expect(text()).toContain('目标已读且为空')
})
it('内外切换不串用:对外的不可读不会被内对的空顶替', async () => {
await mount()
expect(text()).toContain('目标已读且为空')
await click('对外知识库')
expect(text()).toContain('无法读取目标整库状态')
expect(text()).not.toContain('目标已读且为空')
expect(text()).not.toContain('目标非空')
expect(buttonLabels()).not.toContain('建立目标权威基线(维护隔离)')
overviews.external = reply(overviewBody('external', emptyTarget))
await click('刷新')
expect(text()).toContain('目标已读且为空')
expect(text()).not.toContain('无法读取目标整库状态')
await click('对内知识库')
expect(text()).toContain('目标已读且为空')
})
it('当前范围没有自己的 overview 时不借用别的范围分类', async () => {
// 服务端把对内的 overview 回给了对外请求:scope 不匹配,等于没有观测。
overviews.external = reply(overviewBody('internal', emptyTarget))
await mount()
await click('对外知识库')
expect(text()).toContain('还没有读到')
expect(text()).not.toContain('目标已读且为空')
expect(text()).not.toContain('目标非空')
expect(buttonLabels()).not.toContain('建立目标权威基线(维护隔离)')
})
})
+147 -2
View File
@@ -184,8 +184,9 @@ describe('ReleaseTargetsView(确认稿布局)', () => {
it('未接入项显示“未接入/未知”,不用 0 / 无变化冒充', () => {
const html = render()
expect(html).toContain('生产当前')
// 生产没有权威版本时必须写未知,不能拿测试快照顶替
expect(html).toContain('未知(尚未建立权威版本)')
// 生产没有权威版本时必须写"非空且尚无权威版本",不能拿测试快照顶替,
// 也不能把所有非 known 一律写成"未知"(空目标与读不到是另外两类)。
expect(html).toContain('目标非空,尚无权威版本')
expect(html).toContain('尚无记录 · 生产与测试的文档级差异读取未接线')
expect(html).toContain('未接入')
})
@@ -708,6 +709,150 @@ describe('ReleaseTargetsView(确认稿布局)', () => {
})
})
// 后端 /api/v1/library/overview 已经区分四类生产目标状态(library_flow.go unknownLibraryTarget、
// library_baseline.go:81 的空目标 409)。页面此前只判断 state === 'known',于是"已证空"和
// "读不到"都被写成"未知"、并给出必然 409 的"建立目标权威基线"入口。
describe('生产目标四类状态:known / 空 / 非空 / 不可读', () => {
const baseline = { canStart: true, pending: false, onStart: vi.fn() }
const emptyTarget: LibraryOverview['production'] = {
state: 'unknown',
empty_baseline: true,
reason: '生产目标可读且为空:本次是首次发布,写入前仍会生成完整预备份',
}
const nonEmptyTarget: LibraryOverview['production'] = {
state: 'unknown',
reason: '目标非空且尚无权威版本',
}
const unreadableTarget: LibraryOverview['production'] = {
state: 'unreachable',
reason: '无法读取目标整库状态:请检查目标连接后重试,不能据此判定空库或建立基线',
}
const knownTarget: LibraryOverview['production'] = {
state: 'known',
version: {
id: 'v-known',
kind: 'production_effective',
snapshot_id: 'c'.repeat(64),
version_label: '生效 20261030-01',
source_job_id: 'libsync-known',
evidence: {},
created_by: 'admin',
created_at: '2026-10-30T09:00:00Z',
},
}
it('空目标:写明"已读且为空/首次发布/写前仍预备份",且不给会 409 的建基线入口', () => {
const html = render({
baseline,
overview: {
...overview,
production: emptyTarget,
// 接口在空基线上不再给"建立基线"缺项(librarySyncGateWithGrant:657-661)
gate: { sync_allowed: false, reasons: ['没有与目标快照匹配的有效发布授权'] },
},
})
expect(html).toContain('目标已读且为空')
expect(html).toContain('首次发布')
expect(html).toContain('写前仍会生成完整预备份')
// 页面不能把"已证空"写成未知,也不能引导去建一个必然被 409 拒绝的基线。
expect(html).not.toContain('未知(尚未建立权威版本)')
expect(html).not.toMatch(/建立目标权威基线(维护隔离)<\/button>/)
})
it('非空且无生效版本:仍指向既有基线入口,不谎称空库', () => {
const html = render({
baseline,
overview: {
...overview,
production: nonEmptyTarget,
gate: {
sync_allowed: false,
reasons: [
'目标非空且没有生效版本记录:先建立目标权威基线(维护隔离下捕获整库并逐类校验),或确认目标确实是空库',
],
},
},
})
expect(html).toContain('目标非空')
expect(html).toMatch(/建立目标权威基线(维护隔离)<\/button>/)
expect(html).not.toContain('目标已读且为空')
})
it('不可读:照实说读取失败,既不认定空也不认定非空,更不引导建基线', () => {
const html = render({
baseline,
overview: {
...overview,
production: unreadableTarget,
gate: { sync_allowed: false, reasons: [unreadableTarget.reason!] },
},
})
expect(html).toContain('无法读取目标整库状态')
expect(html).not.toContain('目标已读且为空')
expect(html).not.toContain('目标非空')
expect(html).not.toMatch(/建立目标权威基线(维护隔离)<\/button>/)
})
it('known:显示权威生效版本与快照摘要,不再写未知', () => {
const html = render({ baseline, overview: { ...overview, production: knownTarget } })
expect(html).toContain('生效 20261030-01')
expect(html).toContain(knownTarget.version!.snapshot_id.slice(0, 12))
expect(html).not.toContain('未知(尚未建立权威版本)')
expect(html).not.toMatch(/建立目标权威基线(维护隔离)<\/button>/)
})
// 真正的一次挂载刷新由 ReleaseTargets.mount.test.tsx 覆盖:三次 renderToStaticMarkup
// 只是三个互不相关的静态输入,不执行 load/effect/fetch,不能算刷新证据。
// F1:没有读到 overview 时,页面自己造的 {state:'unknown'} 与后端"已探测且非空"
// 在 JSON 上长得一模一样。缺观测的三条真实路径都不能落进 nonempty 分支,
// 也不能提供会返回 409 的建基线入口。
it('没有目标观测时不冒充非空:初始加载 / 整体读取失败 / 范围不匹配', () => {
const cases: Array<[string, Partial<ViewProps>, string]> = [
['初始加载中', { loading: true, overview: null }, '正在读取目标状态'],
['初始 overview 请求失败', { error: '发布状态读取失败(HTTP 503)', overview: null }, '不能据此判定空或非空'],
[
'选中对外但只有对内 overview',
{ scope: 'external', overview: { ...overview, scope: 'internal' } },
'还没有读到',
],
]
for (const [name, overrides, expected] of cases) {
const html = render({ baseline, ...overrides })
expect(html, name).toContain(expected)
expect(html, name).not.toContain('目标非空')
expect(html, name).not.toContain('目标已读且为空')
expect(html, name).not.toMatch(/建立目标权威基线(维护隔离)<\/button>/)
}
})
it('刷新失败但保留旧观测时标成上次观测,不冒充刚验证', () => {
const html = render({
baseline,
error: '发布状态读取失败(HTTP 502)',
overview: { ...overview, production: emptyTarget },
})
expect(html).toContain('目标已读且为空')
expect(html).toContain('上次观测 · 刷新失败,可能已过期')
expect(html).not.toContain('正在读取目标状态')
expect(html).not.toMatch(/建立目标权威基线(维护隔离)<\/button>/)
})
it('空目标但没有测试封存:启动同步保持禁用并说明缺候选', () => {
const html = render({
baseline,
overview: {
...overview,
production: emptyTarget,
test_sealed: undefined,
test_sealed_reason: '还没有该目标的测试封存',
},
})
expect(html).toMatch(/<button[^>]*class="rel-primary"[^>]*disabled/)
expect(html).toContain('尚无测试封存')
})
})
// E5:轮询必须覆盖"投递 → 终态读回"。
// 旧实现只按封存队列的作业判断,于是点了同步/恢复/建立基线之后页面根本不刷新,
// 看上去像"还没启动";要么反过来,一个失败作业被永远轮询下去。
+86 -13
View File
@@ -159,6 +159,32 @@ function scopeView(
const scopeName = (scope: Scope) => (scope === 'internal' ? '对内知识库' : '对外知识库')
// 生产目标必须按接口的四类状态原样呈现:把"已读且为空"和"读不到"都写成"未知",
// 会让人去点一个必然被拒绝的"建立基线"(后端对空目标返回 409 library_baseline_target_empty,
// 对读不到的目标返回 503 library_baseline_unprobeable)。
type ProductionTargetClass = 'known' | 'empty' | 'nonempty' | 'unreachable' | 'no-observation'
// 只有真正读到的 overview 才能分类。页面自己补的 {state:'unknown'} 与后端
// "已探测且非空"在 JSON 上无法区分,所以缺观测必须单列一类(no-observation),
// 否则首次加载、整体读取失败、当前范围没数据都会被误报成"目标非空"(F1)。
function productionTargetClass(production: LibraryOverview['production'] | null | undefined): ProductionTargetClass {
if (!production) return 'no-observation'
if (production.state === 'known' && production.version) return 'known'
if (production.state === 'unreachable') return 'unreachable'
// unknown 有两种:可读且空(空基线,首次发布合法起点)与可读但非空(缺权威版本)。
return production.empty_baseline ? 'empty' : 'nonempty'
}
// 刷新失败时页面会保留上一次的 overview:那份观测仍是当时的真实结果,
// 但已经不是刚刚验证过的状态,必须标出来,不能混作当前结论。
const STALE_NOTE = '上次观测 · 刷新失败,可能已过期'
function noObservationText(loading: boolean, error: string | null): string {
if (loading) return '正在读取目标状态(尚未确认空或非空)'
if (error) return '读取失败,还没有读到目标状态(不能据此判定空或非空)'
return '还没有读到该范围的目标状态'
}
// 不可发起时必须说清是哪一条不满足:读的是接口给出的真实原因(执行器不可达、
// 指向别的实例、降级、观测过期),不是一句写死的"通道未接线"。
function captureHint(view: ScopeView, canWrite: boolean): string {
@@ -684,6 +710,8 @@ function ChangeTable({ diff }: { diff: LibraryOverview['diff'] }) {
function ProtectionCard({
production,
productionNote,
stale,
prebackup,
gate,
authorization,
@@ -692,7 +720,10 @@ function ProtectionCard({
baseline,
onOpen,
}: {
production: LibraryOverview['production']
production: LibraryOverview['production'] | null
// 没有目标观测时显示的原因(加载中 / 整体读取失败 / 当前范围没数据)。
productionNote: string
stale: boolean
prebackup: LibraryOverview['prebackup']
gate: LibraryOverview['gate']
authorization: LibraryOverview['authorization']
@@ -702,8 +733,11 @@ function ProtectionCard({
onOpen: () => void
}) {
const backupKnown = prebackup.state === 'known' && prebackup.version
// 基线入口只在"目标非空、却没有生效版本"时才是答案;空库走首次同步(预备份+登记)。
const baselineNeeded = production.state !== 'known' && targetSealed
// 基线入口只在"目标非空、却没有生效版本"时才是答案;空库走首次同步(预备份+登记),
// 读不到或还没读到观测时必须先修连接/等读取,两者都不能被引导到会 409/503 的基线入口。
const productionTarget = productionTargetClass(production)
const baselineNeeded = productionTarget === 'nonempty' && targetSealed
const staleNote = stale ? `(${STALE_NOTE})` : ''
return (
<aside className="rel-panel rel-protection">
<header className="rel-panel-head">
@@ -735,9 +769,23 @@ function ProtectionCard({
</dd>
<dt>生产当前版本</dt>
<dd>
{production.state === 'known' && production.version
? production.version.version_label || production.version.snapshot_id.slice(0, 12)
: '未知(生产还没有权威版本记录)'}
{productionTarget === 'known' && production?.version ? (
<>
{production.version.version_label || production.version.snapshot_id.slice(0, 12)}
<br />
<small>
快照 {production.version.snapshot_id.slice(0, 12)} · 登记于 {stamp(production.version.created_at)}
</small>
</>
) : productionTarget === 'empty' ? (
`目标已读且为空:首次发布还没有生效版本(写前仍会生成完整预备份)${staleNote}`
) : productionTarget === 'unreachable' ? (
`${production?.reason || '读取失败:无法确认目标是否为空'}${staleNote}`
) : productionTarget === 'nonempty' ? (
`未知(目标非空且还没有权威版本)${staleNote}`
) : (
productionNote
)}
</dd>
<dt>备份范围</dt>
<dd>数据库 / 资源 / 索引 / Wiki</dd>
@@ -784,7 +832,10 @@ function ProtectionCard({
interface ReadyProps {
view: ScopeView
diff: LibraryOverview['diff']
production: LibraryOverview['production']
production: LibraryOverview['production'] | null
// 缺观测时的说明与"旧观测"标记都由上层按 loading/error 判定,子组件只负责显示。
productionNote: string
stale: boolean
prebackup: LibraryOverview['prebackup']
gate: LibraryOverview['gate']
authorization: LibraryOverview['authorization']
@@ -801,6 +852,8 @@ function ReadyBody({
view,
diff,
production,
productionNote,
stale,
prebackup,
gate,
authorization,
@@ -819,6 +872,8 @@ function ReadyBody({
<ChangeTable diff={diff} />
<ProtectionCard
production={production}
productionNote={productionNote}
stale={stale}
prebackup={prebackup}
gate={gate}
authorization={authorization}
@@ -1368,7 +1423,13 @@ export function ReleaseTargetsView(props: ViewProps) {
const active = Boolean(view.activeJob) || view.job?.state === 'running' || view.job?.state === 'pending'
// 三类版本严格分开:target 是测试封存,production 是生产权威版本,prebackup 是本次预备份。
const target = view.overview?.test_sealed
const production = view.overview?.production ?? { state: 'unknown' as const }
// 没有 overview 时不能补一个 {state:'unknown'} 再当成"已探测且非空":
// production 保持 null,分类走 no-observation。
const production = view.overview?.production ?? null
const productionClass = productionTargetClass(production)
// 刷新失败但仍保留上一份 overview:显示可以继续,但必须标成上次观测。
const productionStale = Boolean(error) && production !== null
const productionNote = noObservationText(loading, error)
const prebackup = view.overview?.prebackup ?? { state: 'unknown' as const }
const diff = view.overview?.diff ?? {
state: 'unknown' as const,
@@ -1462,12 +1523,22 @@ export function ReleaseTargetsView(props: ViewProps) {
</div>
<p className="rel-versionline">
生产当前{' '}
{production.state === 'known' && production.version ? (
<b>{production.version.version_label || `生效 ${production.version.snapshot_id.slice(0, 12)}`}</b>
{productionClass === 'known' && production?.version ? (
<b>
{production.version.version_label || `生效 ${production.version.snapshot_id.slice(0, 12)}`}
{' · '}
快照 {production.version.snapshot_id.slice(0, 12)}
</b>
) : productionClass === 'empty' ? (
<b>目标已读且为空(首次发布)</b>
) : productionClass === 'unreachable' ? (
<b className="rel-unknown">读取失败:{production?.reason || '无法读取目标整库状态'}</b>
) : productionClass === 'nonempty' ? (
<b className="rel-unknown">目标非空,尚无权威版本</b>
) : (
<b className="rel-unknown">未知(尚未建立权威版本)</b>
)}{' '}
→ 拟发布{' '}
<b className="rel-unknown">{productionNote}</b>
)}
{productionStale ? <em className="rel-scope">({STALE_NOTE})</em> : null} → 拟发布{' '}
<b>{target ? `封存 ${target.snapshot_id.slice(0, 12)} · ${stamp(target.created_at)}` : '还没有测试封存'}</b> ·{' '}
{publishStateText(view.job)}
</p>
@@ -1549,6 +1620,8 @@ export function ReleaseTargetsView(props: ViewProps) {
view={view}
diff={diff}
production={production}
productionNote={productionNote}
stale={productionStale}
prebackup={prebackup}
gate={gate}
authorization={authorization}
+11 -1
View File
@@ -416,9 +416,19 @@ export interface LibraryVersionRecord {
}
export interface LibraryKnown {
state: 'known' | 'unknown'
/**
* 后端 /api/v1/library/overview 的真实状态:
* - known:有权威生效版本;
* - unknown + empty_baseline:目标可读、确实为空,是首次发布的合法起点;
* - unknown(无 empty_baseline):目标非空却没有权威版本;
* - unreachable:读不到目标,既不能判定空也不能判定非空。
*/
state: 'known' | 'unknown' | 'unreachable'
version?: LibraryVersionRecord
reason?: string
/** 目标可读且内容为空:首次发布。只证明生产范围内容表计数为 0,
* ES/对象身份与残留仍由执行侧预备份/准入核验;页面不得据此宣称"三存储已验证"。 */
empty_baseline?: boolean
/** 只在预备份上有意义:这份备份的内容是否就是当前拟发布目标。 */
bound_to_target?: boolean
}