主人 2026-10-03 拍板「注释一律中文」;gateway 至此整包注释中文化完成(本会话共 3 批: 已推库 16 个文件 + 本批剩余文件)。 校验:机械校验(非注释改动行)= 空;剥注释后代码文本逐行比对全等(不一致文件数 0); 全树 go test ./... 58 包 ok / 0 FAIL;交叉编译绿。
311 lines
11 KiB
Go
311 lines
11 KiB
Go
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)
|
||
}
|
||
}
|