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 }