Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 47be4ae502 | |||
| 6827fa922f | |||
| c31a7df4ca | |||
| 770252b55a | |||
| c256f2985c | |||
| f1cddfb601 | |||
| d2ff089b95 | |||
| 12b37690ec |
@@ -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:新分支推送,范围按默认分支为基准判)"
|
||||
|
||||
@@ -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
@@ -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没有注入,跳过不是通过"}
|
||||
]
|
||||
}
|
||||
|
||||
Generated
+527
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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('建立目标权威基线(维护隔离)')
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user