Files
knowledge-governance/internal/httpapi/library_read_paths_test.go
姚章浩 fe6e218d9c
build / build (push) Successful in 1m17s
fix(library): 整库只读接口改清单级读取,修掉真实超时
候选归档 79 版 / 56MB blob 上,overview、storage、snapshots 三个只读接口
都在 10s 请求期限后返回 503。分段计时定位到唯一瓶颈是按版本重读全部 blob:
Open 0ms、LoadManifest×79 11~22ms、ContentUsage 15~25ms、
Usage(逐版 Load 验字节)13.75~14.11s;单版 Load 261~290ms。

- Usage 拆成 Usage(逐版复核字节,同步/恢复与运维 CLI 用)与
  UsageFromManifests(同一份去重实现,只做清单级校验),列举路径改用后者;
- library_storage.go 每版两次 Load、snapshots 每版一次 Load 改为一次清单读取;
- wiki/文档差异只读所需部件,被读部件仍由 OpenPart 复核摘要;
- LoadManifest 与 Load 共用同一套清单判据(严格解码 + 重新编码校验);
- 列举响应带 verification=manifest / verified=false,详情整版复核后才是 content,
  避免把"清单可读"读成"完整性已验证";
- libraryWithDeadline 去掉超时后再写已结束响应的 goroutine 写法
  (候选环境实测 http: superfluous response.WriteHeader call)。

回归:多版本共享大 blob 时枚举不读 blob 字节(篡改后枚举仍成功),
而 Load/OpenPart/Usage 仍拒绝坏 blob;坏清单两条路径都整条报错。
make verify 15 项全绿(local 档案)。
2026-10-28 21:52:14 +08:00

269 lines
9.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package httpapi
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"knowledge-governance/internal/librarysnapshot"
)
// 整库只读接口(overview / storage / snapshots)的回归:
//
// 候选归档里 79 个版本共享少量大 blob。按版本重读全部 blob 会让每个接口都
// 打到请求级超时(实测 10s 后 503),并把"列版本"变成 O(全部版本字节)。
// 这个夹具把形状缩到最小:多版本共享同一个大部件,枚举必须给出全部版本与真实去重,
// 而被篡改的字节仍然要在执行路径上被拒绝。
// sealSharedVersions 造 count 个内容完全一样的版本:它们共享全部 blob。
func sealSharedVersions(t *testing.T, root string, count, documents int) []string {
t.Helper()
archive, err := librarysnapshot.Open(root, 4*1024*1024)
if err != nil {
t.Fatal(err)
}
rows := func(n int) string {
items := make([]string, n)
for i := range items {
items[i] = fmt.Sprintf(`{"id":"00000000-0000-4000-8000-%012d"}`, i)
}
return "[" + strings.Join(items, ",") + "]"
}
parts := []struct {
kind, name string
count int64
body string
}{
{"database", "knowledges", int64(documents), rows(documents)},
{"database", "chunks", int64(documents) * 8, rows(documents * 8)},
{"database", "resources", 0, rows(0)},
{"database", "resource_bindings", 0, rows(0)},
{"database", "knowledge_tags", 0, rows(0)},
{"database", "knowledge_tag_relations", 0, rows(0)},
{"index", "chunks", int64(documents) * 30, rows(documents * 30)},
{"wiki", "wiki_pages", 3, rows(3)},
{"wiki", "wiki_folders", 1, rows(1)},
{"wiki", "wiki_page_issues", 0, rows(0)},
{"wiki", "wiki_log_entries", 2, rows(2)},
}
// 每个版本各封一次;内容相同 ⇒ blob 复用,归档里只有一份字节。
ids := make([]string, 0, count)
for version := 0; version < count; version++ {
manifest := librarysnapshot.Manifest{ValidationPolicy: librarysnapshot.ArtifactAcceptancePolicy,
Format: librarysnapshot.Format, Scope: "internal", TenantID: 10000,
KnowledgeBaseID: "0c84a098-d3ed-4b82-a165-e6de9691d6a8",
CreatedAt: time.Date(2026, 10, 28, version, 0, 0, 0, time.UTC),
Compatibility: librarysnapshot.Compatibility{WeKnoraVersion: "v0.7.1", SchemaVersion: 74,
RetrieverEngine: "elasticsearch_v8", EmbeddingModel: "text-embedding-v4", EmbeddingDimension: 1024,
IndexMappingSHA256: "137cde2965f3b56521f5fb722b4e0617f46d0e509e571b217fe01e090d5e4d01"}}
for _, part := range parts {
stored, err := archive.Put(bytes.NewReader([]byte(part.body)))
if err != nil {
t.Fatal(err)
}
stored.Kind, stored.Name, stored.Count = part.kind, part.name, part.count
manifest.Parts = append(manifest.Parts, stored)
}
id, err := archive.Seal(manifest)
if err != nil {
t.Fatal(err)
}
ids = append(ids, id)
}
if err := archive.Close(); err != nil {
t.Fatal(err)
}
return ids
}
func libraryFixtureRoot(t *testing.T) string {
t.Helper()
root, err := os.MkdirTemp(os.TempDir(), "kglib-read-")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(root) })
if err = os.Chmod(root, 0700); err != nil {
t.Fatal(err)
}
return root
}
// 多版本共享大 blob:三个只读接口都必须返回完整正确的数据,不能降级或超时。
func TestLibraryReadPathsListSharedVersions(t *testing.T) {
root := libraryFixtureRoot(t)
ids := sealSharedVersions(t, root, 12, 200)
config := Config{Store: &fakeLibraryStore{}, LibraryArchiveRoot: root,
LibraryCapacityBudgetBytes: 1 << 30}
list, err := readLibrarySnapshots(t.Context(), root)
if err != nil {
t.Fatalf("snapshots list: %v", err)
}
if !list.Available || len(list.Snapshots) != len(ids) {
t.Fatalf("list available=%v snapshots=%d want=%d", list.Available, len(list.Snapshots), len(ids))
}
// 共享字节只算一次:12 个内容相同的版本,跨版本内容字节必须等于"唯一 blob 之和",
// 而不是 12 × 单版逻辑字节(后者会把共享 blob 重复计 12 遍)。
one := list.Snapshots[0].LogicalBytes
archive, err := librarysnapshot.Open(root, 4*1024*1024)
if err != nil {
t.Fatal(err)
}
manifest, err := archive.LoadManifest(ids[0])
if err != nil {
t.Fatal(err)
}
archive.Close()
unique := map[string]int64{}
for _, part := range manifest.Parts {
unique[part.SHA256] = part.Bytes
}
var want int64
for _, size := range unique {
want += size
}
if one <= 0 || list.ContentBytes != want {
t.Fatalf("content bytes must deduplicate shared blobs across %d versions: content=%d want=%d logical_one=%d",
len(ids), list.ContentBytes, want, one)
}
if want >= int64(len(ids))*one {
t.Fatalf("dedup produced no gain: content=%d versions×logical=%d", want, int64(len(ids))*one)
}
for _, snapshot := range list.Snapshots {
if snapshot.Totals["documents"] != 200 || snapshot.Totals["index_documents"] != 6000 {
t.Fatalf("snapshot %s lost totals: %+v", snapshot.SnapshotID, snapshot.Totals)
}
// 枚举只证明清单可读,不能自称完整性已验证。
if snapshot.Verified {
t.Fatalf("listing must not claim verified integrity: %+v", snapshot)
}
}
if list.Verification != libraryVerificationManifest || list.VerificationNote == "" {
t.Fatalf("listing must state what it verified: %q / %q", list.Verification, list.VerificationNote)
}
recorder := httptest.NewRecorder()
libraryStorageHandler(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/library/storage", nil), config)
if recorder.Code != http.StatusOK {
t.Fatalf("storage status=%d body=%s", recorder.Code, recorder.Body.String())
}
var storage struct {
Available bool `json:"available"`
Versions []storageVersionView `json:"versions"`
Plan json.RawMessage `json:"plan"`
Notes []string `json:"notes"`
}
if err = json.Unmarshal(recorder.Body.Bytes(), &storage); err != nil {
t.Fatal(err)
}
if !storage.Available || len(storage.Versions) != len(ids) {
t.Fatalf("storage available=%v versions=%d want=%d", storage.Available, len(storage.Versions), len(ids))
}
if len(storage.Plan) == 0 {
t.Fatal("storage must still build the retention preview")
}
if strings.Contains(recorder.Body.String(), "校验失败") {
t.Fatalf("storage must not report a verification failure for intact manifests: %s", recorder.Body.String())
}
// 单版详情是"要动这个版本"的入口:它仍然逐部件复核字节,所以能给出 content 级结论。
detail := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/api/v1/library/snapshots/"+ids[0], nil)
request.SetPathValue("id", ids[0])
librarySnapshotDetail(detail, request, config)
if detail.Code != http.StatusOK {
t.Fatalf("detail status=%d body=%s", detail.Code, detail.Body.String())
}
var body struct {
Snapshot struct {
SnapshotID string `json:"snapshot_id"`
Verified bool `json:"verified"`
} `json:"snapshot"`
Verification string `json:"verification"`
}
if err = json.Unmarshal(detail.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Snapshot.SnapshotID != ids[0] || !body.Snapshot.Verified || body.Verification != libraryVerificationContent {
t.Fatalf("detail must report content-level verification: %+v", body)
}
}
// 坏 blob:枚举照常(它不读字节,且明确标注自己只做了清单级校验),
// 但详情这个执行入口必须拒绝,不能给出"完整"的结论。
func TestLibrarySnapshotDetailRejectsTamperedBlob(t *testing.T) {
root := libraryFixtureRoot(t)
ids := sealSharedVersions(t, root, 2, 40)
archive, err := librarysnapshot.Open(root, 4*1024*1024)
if err != nil {
t.Fatal(err)
}
manifest, err := archive.LoadManifest(ids[0])
if err != nil {
t.Fatal(err)
}
archive.Close()
target := manifest.Parts[0]
for _, part := range manifest.Parts {
if part.Bytes > target.Bytes {
target = part
}
}
path := filepath.Join(root, "blobs", target.SHA256)
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
corrupted := bytes.Repeat([]byte("x"), int(info.Size()))
if err = os.WriteFile(path, corrupted, 0600); err != nil {
t.Fatal(err)
}
config := Config{Store: &fakeLibraryStore{}, LibraryArchiveRoot: root}
list, err := readLibrarySnapshots(t.Context(), root)
if err != nil || !list.Available || len(list.Snapshots) != len(ids) {
t.Fatalf("listing must still enumerate manifests: %+v err=%v", list, err)
}
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/api/v1/library/snapshots/"+ids[0], nil)
request.SetPathValue("id", ids[0])
librarySnapshotDetail(recorder, request, config)
if recorder.Code != http.StatusNotFound {
t.Fatalf("detail must fail closed on a tampered blob: status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
// 坏清单:两个接口都必须报错,不能返回一个"看起来正常"的空/残缺列表。
func TestLibraryReadPathsRejectTamperedManifest(t *testing.T) {
root := libraryFixtureRoot(t)
ids := sealSharedVersions(t, root, 2, 10)
path := filepath.Join(root, "versions", ids[0]+".json")
file, err := os.OpenFile(path, os.O_RDWR, 0600)
if err != nil {
t.Fatal(err)
}
if _, err = file.WriteAt([]byte{' '}, 0); err != nil {
t.Fatal(err)
}
file.Close()
if list, err := readLibrarySnapshots(t.Context(), root); err == nil {
t.Fatalf("snapshots list must fail closed: %+v", list)
}
recorder := httptest.NewRecorder()
libraryStorageHandler(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/library/storage", nil),
Config{Store: &fakeLibraryStore{}, LibraryArchiveRoot: root})
if recorder.Code != http.StatusServiceUnavailable {
t.Fatalf("storage must fail closed: status=%d body=%s", recorder.Code, recorder.Body.String())
}
}