Files
Cyrene_v2/internal/services/gateway/admin_system_storage_test.go
T
AskaEth c4e09afd38
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(admin): 面板「存储」卡片接真数据(含 PG 增强状态)+ 契约 v0.60
- 现状:overviewMetrics.ts:188 的「存储」卡是写死的 value:"SQLite",后端也没有对应读端点(/admin/system/* 只有 revoke-keys/rotate-jwt/backup/restore 四个动作端点)。

- 新增只读端点 GET /api/v1/admin/system/storage(ownerAuth):main{kind,path,fileBytes,walBytes,shmBytes,pageSize,pageCount,freelistPages,freeBytes} + documents{count,bytes} + vector{kind,enabled,ready};只读(SELECT/PRAGMA + os.Stat)、复用活连接不开新池、**绝不含凭据**(只回 kind,不回 host/DSN)、失败一律 500 storage_unavailable(不回全 0 假快照;表缺失也 500 而不是静默 0)。

- vector 真源 = 组合根 app.vectorState()(Configured = DB_PG_URL 非空;Live = 向量索引类型断言成功),零新探测 ⇒ 面板现在能区分「未启用(未配置 DB_PG_URL)/ 启用(pgvector·ready)/ 已配置但未就绪(连接失败,检索降级)」,正好解答主人对「面板只写 SQLite」的困惑。

- 前端:卡片显示真实体积 + WAL/页数/空闲页 + documents 件数与占用 + PG 那一行;取数走与已接线卡片同一条加载/错误态(失败显示 '—' + 端点报错,不静默显示 0)。

- 契约 v0.60(项目红线):api-client(版本段 + 端点表 + 存储状态契约小节 + changelog)与 api-reference(版本号 + 端点表 + 详情小节)同步;字段名三方(代码/Docs/前端)逐字对齐。

- 主会话独立验收:隔离 worktree(ST1 的 14 文件,排除 B1 在途的 chat_documents.go/documents_pdf_test.go)build/vet 绿、全树 59 包 0 FAIL、存储端点 7 用例 PASS、双平台交叉编译绿。

- ⚠️ 该切片自首一起事故:为通过 cmd/cyrene 类型检查跑了 go build ./cmd/cyrene/(单 main 包)⇒ 覆盖了仓库根 cyrene.exe(49,675,264 B / 19:32:19,脏树未 strip);上一个权威产物仍在 cyrene.exe~(39,507,968 B / 19:25:13)。运行中的 PID 477964 未受影响;主会话将在整树干净后重跑 ctrl.bat build 恢复权威产物。
2026-10-04 19:44:53 +08:00

312 lines
10 KiB
Go
Raw 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 gateway
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
)
// storageTestDB 在 dir 下打开一个真实 SQLite 文件库、跑迁移,并按 sizes 插入
// 等量的 documents 行(每行 size 取该元素值),返回路径与**仍打开**的活连接
// —— 存储状态端点用的就是这条连接(Deps.Database)。
func storageTestDB(t *testing.T, dir string, sizes []int64) (string, *sql.DB) {
t.Helper()
ctx := context.Background()
path := filepath.Join(dir, "cyrene.db")
conn, err := db.Open(ctx, path, gwTestLogger())
if err != nil {
t.Fatalf("open file db: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
if err := db.MigrateUp(ctx, conn, gwTestLogger()); err != nil {
t.Fatalf("migrate: %v", err)
}
for i, size := range sizes {
name := "doc-" + string(rune('a'+i)) + ".txt"
if _, err := conn.ExecContext(ctx,
`INSERT INTO documents (name, sha256, mime, size) VALUES (?, ?, ?, ?)`,
name, strings.Repeat("a", 64), "text/plain", size); err != nil {
t.Fatalf("insert document %d: %v", i, err)
}
}
return path, conn
}
// storageStatusDTO 是端点的响应形状(与 internal/services/gateway/admin_system_storage.go
// 的字段逐字对齐);测试用它反解,字段名写错就会解不出来。
type storageStatusDTO struct {
Main struct {
Kind string `json:"kind"`
Path string `json:"path"`
FileBytes int64 `json:"fileBytes"`
WALBytes int64 `json:"walBytes"`
SHMBytes int64 `json:"shmBytes"`
PageSize int64 `json:"pageSize"`
PageCount int64 `json:"pageCount"`
FreelistPages int64 `json:"freelistPages"`
FreeBytes int64 `json:"freeBytes"`
} `json:"main"`
Documents struct {
Count int64 `json:"count"`
Bytes int64 `json:"bytes"`
} `json:"documents"`
Vector struct {
Kind string `json:"kind"`
Enabled bool `json:"enabled"`
Ready bool `json:"ready"`
} `json:"vector"`
}
// getStorageStatus 以一个 owner token 取一次存储状态,返回状态码与原始响应体。
func getStorageStatus(t *testing.T, url string, jwt *auth.JWTService) (int, []byte) {
t.Helper()
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodGet, url+"/api/v1/admin/system/storage", nil, tok)
return resp.StatusCode, body
}
func decodeStorageStatus(t *testing.T, body []byte) storageStatusDTO {
t.Helper()
var st storageStatusDTO
if err := json.Unmarshal(decodeBody(t, body)["data"], &st); err != nil {
t.Fatalf("decode storage status: %v (body=%s)", err, body)
}
return st
}
// --- GET /api/v1/admin/system/storage(存储状态只读端点) --------------------
func TestAdminStorageStatusReportsRealValues(t *testing.T) {
t.Parallel()
dir := t.TempDir()
dbPath, conn := storageTestDB(t, dir, []int64{11, 22, 33})
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{DatabasePath: dbPath, Database: conn})
status, body := getStorageStatus(t, ts.URL, jwt)
if status != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", status, body)
}
st := decodeStorageStatus(t, body)
if st.Main.Kind != "sqlite" {
t.Fatalf("main.kind = %q, want sqlite", st.Main.Kind)
}
if st.Main.Path != dbPath {
t.Fatalf("main.path = %q, want %q", st.Main.Path, dbPath)
}
if st.Main.PageSize <= 0 || st.Main.PageCount <= 0 {
t.Fatalf("page stats = (%d, %d), want both > 0", st.Main.PageSize, st.Main.PageCount)
}
info, err := os.Stat(dbPath)
if err != nil {
t.Fatalf("stat db: %v", err)
}
if st.Main.FileBytes != info.Size() {
t.Fatalf("main.fileBytes = %d, want stat size %d", st.Main.FileBytes, info.Size())
}
if st.Main.FreeBytes != st.Main.PageSize*st.Main.FreelistPages {
t.Fatalf("main.freeBytes = %d, want pageSize*freelistPages = %d",
st.Main.FreeBytes, st.Main.PageSize*st.Main.FreelistPages)
}
if st.Main.WALBytes < 0 || st.Main.SHMBytes < 0 {
t.Fatalf("sidecar bytes negative: wal=%d shm=%d", st.Main.WALBytes, st.Main.SHMBytes)
}
// documents 段必须与插入的行逐字对上(把 count 写死 0 的变异在这里变红)。
if st.Documents.Count != 3 || st.Documents.Bytes != 66 {
t.Fatalf("documents = %+v, want count 3 bytes 66", st.Documents)
}
// 未配置 PG ⇒ 如实报 none,且不假装 ready。
if st.Vector.Kind != "none" || st.Vector.Enabled || st.Vector.Ready {
t.Fatalf("vector = %+v, want {none false false}", st.Vector)
}
}
func TestAdminStorageStatusUnauthenticated(t *testing.T) {
t.Parallel()
dir := t.TempDir()
dbPath, conn := storageTestDB(t, dir, nil)
_, ts, _ := buildEnv(t, cfgDefault(), Deps{DatabasePath: dbPath, Database: conn})
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/system/storage", nil, "")
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401 (body=%s)", resp.StatusCode, body)
}
wantErrorShape(t, body)
}
// TestAdminStorageStatusHidesCredentials 钉住红线:PG URL(可能含密码)绝不
// 出现在响应体里;只回 kind/enabled/ready。
func TestAdminStorageStatusHidesCredentials(t *testing.T) {
t.Parallel()
const dsn = "postgres://cyrene_user:s3cret-pw@db.internal:5432/cyrene?sslmode=disable"
dir := t.TempDir()
dbPath, conn := storageTestDB(t, dir, nil)
cfg := cfgDefault()
cfg.DB.PGURL = dsn
_, ts, jwt := buildEnv(t, cfg, Deps{
DatabasePath: dbPath,
Database: conn,
Vector: VectorState{Configured: true, Live: false},
})
status, body := getStorageStatus(t, ts.URL, jwt)
if status != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", status, body)
}
for _, secret := range []string{dsn, "s3cret-pw", "postgres://", "cyrene_user", "db.internal", "sslmode"} {
if strings.Contains(string(body), secret) {
t.Fatalf("response body leaks %q: %s", secret, body)
}
}
st := decodeStorageStatus(t, body)
// 配置了但索引没跑起来 ⇒ enabled 为真、ready 为假(面板显示「已降级」)。
if st.Vector.Kind != "pgvector" || !st.Vector.Enabled || st.Vector.Ready {
t.Fatalf("vector = %+v, want {pgvector true false}", st.Vector)
}
}
func TestAdminStorageStatusVectorReady(t *testing.T) {
t.Parallel()
dir := t.TempDir()
dbPath, conn := storageTestDB(t, dir, nil)
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{
DatabasePath: dbPath,
Database: conn,
Vector: VectorState{Configured: true, Live: true},
})
status, body := getStorageStatus(t, ts.URL, jwt)
if status != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", status, body)
}
st := decodeStorageStatus(t, body)
if st.Vector.Kind != "pgvector" || !st.Vector.Enabled || !st.Vector.Ready {
t.Fatalf("vector = %+v, want {pgvector true true}", st.Vector)
}
}
// TestAdminStorageStatusUnwired 覆盖三种「没接线」:路径空 / 连接空 / 两者都空。
// 一律 500 + storage_unavailable,绝不回一个全 0 的假快照。
func TestAdminStorageStatusUnwired(t *testing.T) {
t.Parallel()
dir := t.TempDir()
barePath, conn := storageBareDB(t, dir, "unwired.db")
cases := []struct {
name string
deps Deps
}{
{"no path no conn", Deps{}},
{"conn but no path", Deps{Database: conn}},
{"path but no conn", Deps{DatabasePath: barePath}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, ts, jwt := buildEnv(t, cfgDefault(), tc.deps)
status, body := getStorageStatus(t, ts.URL, jwt)
if status != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500 (body=%s)", status, body)
}
wantErrorShape(t, body)
var env struct {
Code string `json:"code"`
}
if err := json.Unmarshal(decodeBody(t, body)["error"], &env); err != nil {
t.Fatalf("decode error envelope: %v", err)
}
if env.Code != "storage_unavailable" {
t.Fatalf("error code = %q, want storage_unavailable", env.Code)
}
})
}
}
// TestAdminStorageStatusMissingDocumentsTable 钉住「不静默报 0」:库没跑迁移
// (documents 表不存在)时如实 500,而不是回 documents.count = 0。
func TestAdminStorageStatusMissingDocumentsTable(t *testing.T) {
t.Parallel()
dir := t.TempDir()
barePath, conn := storageBareDB(t, dir, "bare.db")
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{DatabasePath: barePath, Database: conn})
status, body := getStorageStatus(t, ts.URL, jwt)
if status != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500 (body=%s)", status, body)
}
wantErrorShape(t, body)
}
// TestAdminStorageStatusIsReadOnly 钉住「只读」:查询前后 documents 行数与数据
// 目录里的文件集合都不变(既没有写、也没有顺手备份)。
func TestAdminStorageStatusIsReadOnly(t *testing.T) {
t.Parallel()
dir := t.TempDir()
dbPath, conn := storageTestDB(t, dir, []int64{5, 7})
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{DatabasePath: dbPath, Database: conn})
before := listDir(t, dir)
status, body := getStorageStatus(t, ts.URL, jwt)
if status != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", status, body)
}
var count int64
if err := conn.QueryRowContext(context.Background(),
`SELECT COUNT(*) FROM documents`).Scan(&count); err != nil {
t.Fatalf("count documents: %v", err)
}
if count != 2 {
t.Fatalf("documents rows = %d, want 2 (endpoint wrote something)", count)
}
after := listDir(t, dir)
if !sameFileSet(before, after) {
t.Fatalf("data dir changed: before=%v after=%v", before, after)
}
}
// storageBareDB 打开 dir/name 的 SQLite 文件库但**不跑迁移**(用于演练
// 「表不存在」「连接未接线」这两类边界)。
func storageBareDB(t *testing.T, dir, name string) (string, *sql.DB) {
t.Helper()
path := filepath.Join(dir, name)
conn, err := db.Open(context.Background(), path, gwTestLogger())
if err != nil {
t.Fatalf("open bare db: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
return path, conn
}
// listDir 返回 dir 下的文件名集合,用于「端点没动文件系统」的断言。
func listDir(t *testing.T, dir string) map[string]bool {
t.Helper()
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("read dir: %v", err)
}
names := make(map[string]bool, len(entries))
for _, e := range entries {
names[e.Name()] = true
}
return names
}
// sameFileSet 比较两个文件名集合(与顺序无关)。
func sameFileSet(a, b map[string]bool) bool {
if len(a) != len(b) {
return false
}
for name := range a {
if !b[name] {
return false
}
}
return true
}