Files
Cyrene_v2/internal/services/gateway/admin_system_test.go
T
AskaEth 074ffc4ebf
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(gateway): gateway 剩余文件注释改中文(逐行 1:1,代码零字节改动)
主人 2026-10-03 拍板「注释一律中文」;gateway 至此整包注释中文化完成(本会话共 3 批:
已推库 16 个文件 + 本批剩余文件)。
校验:机械校验(非注释改动行)= 空;剥注释后代码文本逐行比对全等(不一致文件数 0);
全树 go test ./... 58 包 ok / 0 FAIL;交叉编译绿。
2026-10-03 22:28:25 +08:00

311 lines
11 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"
"encoding/json"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
)
// newFileDB 在 dir/<name> 创建真实 SQLite 数据库文件、迁移它、
// 然后关闭句柄,返回文件路径。网关的备份/恢复端点
// 会自己打开连接,因此这里不保持句柄打开。
func newFileDB(t *testing.T, dir, name string) string {
t.Helper()
path := filepath.Join(dir, name)
ctx := context.Background()
conn, err := db.Open(ctx, path, gwTestLogger())
if err != nil {
t.Fatalf("open file db: %v", err)
}
if err := db.MigrateUp(ctx, conn, gwTestLogger()); err != nil {
_ = conn.Close()
t.Fatalf("migrate: %v", err)
}
if err := conn.Close(); err != nil {
t.Fatalf("close file db: %v", err)
}
return path
}
// --- system.revoke_keys(吊销全部密钥) -------------------------------------
func TestAdminRevokeAllKeys(t *testing.T) {
t.Parallel()
keys := auth.NewAPIKey(config.AuthConfig{})
for _, role := range []auth.Role{auth.RoleOwner, auth.RoleAdmin, auth.RoleViewer} {
if _, err := keys.Generate(role); err != nil {
t.Fatalf("generate key: %v", err)
}
}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Users: keys})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/revoke-keys", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var res struct {
Revoked int `json:"revoked"`
}
if err := json.Unmarshal(decodeBody(t, body)["data"], &res); err != nil {
t.Fatalf("decode: %v", err)
}
if res.Revoked != 3 {
t.Fatalf("revoked = %d, want 3", res.Revoked)
}
if len(keys.List()) != 0 {
t.Fatalf("key table not empty after RevokeAll: %d", len(keys.List()))
}
}
func TestAdminRevokeAllKeysUnwired(t *testing.T) {
t.Parallel()
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // 无 Users store
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/revoke-keys", nil, tok)
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500 (body=%s)", resp.StatusCode, body)
}
wantErrorShape(t, body)
}
// --- system.rotate_jwt(轮换 JWT) -----------------------------------------
func TestAdminRotateJWT(t *testing.T) {
t.Parallel()
jwt := auth.New(config.AuthConfig{JWTSecret: testSecret})
_, ts, _ := buildEnv(t, cfgDefault(), Deps{Auth: jwt})
// 该路由仅 owner;验证持有当前秘密的 agent 能通过。
tok := signAny(t, jwt, "acct", auth.RoleOwner)
newSecret := strings.Repeat("Z", 40)
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/rotate-jwt",
map[string]any{"newSecret": newSecret}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var res struct {
Rotated bool `json:"rotated"`
Note string `json:"note"`
}
if err := json.Unmarshal(decodeBody(t, body)["data"], &res); err != nil {
t.Fatalf("decode: %v", err)
}
if !res.Rotated || res.Note == "" {
t.Fatalf("res = %+v, want rotated with note", res)
}
// 轮换前的 token 不再通过校验。
if _, err := jwt.Verify(tok); err == nil {
t.Fatal("pre-rotation token still verifies after RotateSecret")
}
// 新秘密绝不在响应体里回显,过短的也不会被接受。
if strings.Contains(string(body), newSecret) {
t.Fatalf("new secret leaked in response body: %s", body)
}
}
func TestAdminRotateJWTShortSecret(t *testing.T) {
t.Parallel()
jwt := auth.New(config.AuthConfig{JWTSecret: testSecret})
_, ts, _ := buildEnv(t, cfgDefault(), Deps{Auth: jwt})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/rotate-jwt",
map[string]any{"newSecret": "short"}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
wantErrorShape(t, body)
// 被拒绝的轮换之后,原秘密保持不变。
if _, err := jwt.Verify(tok); err != nil {
t.Fatalf("token invalid after rejected rotation: %v", err)
}
}
func TestAdminRotateJWTMissingSecret(t *testing.T) {
t.Parallel()
jwt := auth.New(config.AuthConfig{JWTSecret: testSecret})
_, ts, _ := buildEnv(t, cfgDefault(), Deps{Auth: jwt})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, _ := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/rotate-jwt",
map[string]any{"newSecret": " "}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
}
// --- system.backup(备份) --------------------------------------------------
func TestAdminBackupProducesFile(t *testing.T) {
t.Parallel()
dir := t.TempDir()
dbPath := newFileDB(t, dir, "cyrene.db")
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{DatabasePath: dbPath})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/backup", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var res struct {
Path string `json:"path"`
Size int64 `json:"size"`
}
if err := json.Unmarshal(decodeBody(t, body)["data"], &res); err != nil {
t.Fatalf("decode: %v", err)
}
if !strings.HasPrefix(res.Path, dir) {
t.Fatalf("backup path %q is not under data dir %q", res.Path, dir)
}
if res.Size <= 0 {
t.Fatalf("backup size = %d, want > 0", res.Size)
}
if info, err := os.Stat(res.Path); err != nil || info.Size() != res.Size {
t.Fatalf("backup file stat = (%v, %d), want no error and size %d", err, info.Size(), res.Size)
}
}
func TestAdminBackupUnwired(t *testing.T) {
t.Parallel()
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // 无 DatabasePath
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/backup", nil, tok)
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500 (body=%s)", resp.StatusCode, body)
}
wantErrorShape(t, body)
}
// --- system.restore(恢复) -------------------------------------------------
func TestAdminRestoreReplacesAndPreBacksUp(t *testing.T) {
t.Parallel()
dir := t.TempDir()
dbPath := newFileDB(t, dir, "cyrene.db")
backupSrc := newFileDB(t, dir, "backup.db")
// 通过追加一个标记,让备份源与活的 db 明显不同。
// 恢复只复制字节、绝不打开源,因此该标记会
// 存活进被替换的活文件,证明替换确实发生过。
srcFile, err := os.OpenFile(backupSrc, os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
t.Fatalf("open backup source: %v", err)
}
if _, err := srcFile.WriteString("RESTORE-MARKER"); err != nil {
t.Fatalf("append marker: %v", err)
}
if err := srcFile.Close(); err != nil {
t.Fatalf("close backup source: %v", err)
}
liveBefore, err := os.ReadFile(dbPath)
if err != nil {
t.Fatalf("read live db before: %v", err)
}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{DatabasePath: dbPath})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
// 此时尚无既有备份。
before, _ := filepath.Glob(filepath.Join(dir, "backup-*.db"))
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/restore",
map[string]any{"path": backupSrc}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var res struct {
Restored bool `json:"restored"`
Backup string `json:"backup"`
Note string `json:"note"`
}
if err := json.Unmarshal(decodeBody(t, body)["data"], &res); err != nil {
t.Fatalf("decode: %v", err)
}
if !res.Restored || res.Note == "" {
t.Fatalf("res = %+v, want restored with note", res)
}
// 当前 DB 在替换前已被快照(出现了一个新备份)。
after, _ := filepath.Glob(filepath.Join(dir, "backup-*.db"))
if len(after) != len(before)+1 {
t.Fatalf("backup count before=%d after=%d, want one more pre-restore snapshot", len(before), len(after))
}
// 活 DB 已被替换为备份源的字节,因此它现在携带
// 该标记,与恢复前的内容不同。
liveAfter, err := os.ReadFile(dbPath)
if err != nil {
t.Fatalf("read live db after: %v", err)
}
if !strings.Contains(string(liveAfter), "RESTORE-MARKER") {
t.Fatalf("live db not replaced (marker missing); before len=%d after len=%d", len(liveBefore), len(liveAfter))
}
if string(liveAfter) == string(liveBefore) {
t.Fatal("live db unchanged after restore")
}
}
func TestAdminRestoreRejectsTraversal(t *testing.T) {
t.Parallel()
dir := t.TempDir() // 数据目录
outside := t.TempDir() // 兄弟目录,位于数据目录之外
dbPath := newFileDB(t, dir, "cyrene.db")
outsideBackup := newFileDB(t, outside, "outside.db")
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{DatabasePath: dbPath})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/restore",
map[string]any{"path": outsideBackup}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
wantErrorShape(t, body)
}
func TestAdminRestoreRejectsMissingFile(t *testing.T) {
t.Parallel()
dir := t.TempDir()
dbPath := newFileDB(t, dir, "cyrene.db")
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{DatabasePath: dbPath})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/restore",
map[string]any{"path": filepath.Join(dir, "does-not-exist.db")}, tok)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status = %d, want 404 (body=%s)", resp.StatusCode, body)
}
wantErrorShape(t, body)
}
func TestAdminRestoreUnwired(t *testing.T) {
t.Parallel()
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // 无 DatabasePath
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/restore",
map[string]any{"path": "x"}, tok)
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500 (body=%s)", resp.StatusCode, body)
}
wantErrorShape(t, body)
}
func TestAdminRestoreRequiresPath(t *testing.T) {
t.Parallel()
dir := t.TempDir()
dbPath := newFileDB(t, dir, "cyrene.db")
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{DatabasePath: dbPath})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, _ := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/restore", map[string]any{"path": ""}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
}