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/ 创建真实 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) } }