Files
Cyrene_v2/internal/services/gateway/admin_system_test.go
T
Aska 1ebc82fffd
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat: JWT过期自动重置 + 用户端登录页 + 系统设置前三个真实实现
agent-111 JWT过期重置+系统设置去占位:
- ws.ts 识别401/过期 markAuthExpired(清token+停重连+expired态),isTokenExpired;wsParse.ts拆出压行371;scheduleReconnect有token未过期才重连
- api.ts REST 401→auth_expired+清token+引导重登;login走skipAuthExpired密码错不误清
- types.ts WsStatus加expired;TopBar/Nav expired显示登录过期;System.tsx去alert占位改4张事实卡+合并Notice

agent-113 用户端登录页:
- components/Login.tsx Miuix登录卡(用户名/密码→login()→setAuthToken);App !getAuthToken()→<Login>;AdminApp LoginGate指向

agent-112 系统设置前三个(owner+审计):
- auth.APIKey.RevokeAll(吊销全部key);auth.JWTService.RotateSecret(≥32校验+并发安全+新密钥不回显)
- gateway admin_system.go: POST revoke-keys/rotate-jwt/backup(VACUUM INTO)/restore(防路径穿越+恢复前自动备份+原子替换+需重启)
- wire.go 传DatabasePath;全仓 build/vet/test绿+交叉编译;api-client v0.17系统设置契约

实机验证: 登录(role=owner)+revoke-keys 200+渠道列表含kook; 全仓绿+前端build绿; .env密码仅登录验证不打印
2026-09-07 13:57:36 +08:00

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 creates a real SQLite database file at dir/<name>, migrates it, and
// closes the handle, returning the file path. The gateway backup/restore
// endpoints open their own connections, so the handle is not held open here.
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
}
// --- 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{}) // no 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)
}
// --- rotate-jwt --------------------------------------------------------------
func TestAdminRotateJWT(t *testing.T) {
t.Parallel()
jwt := auth.New(config.AuthConfig{JWTSecret: testSecret})
_, ts, _ := buildEnv(t, cfgDefault(), Deps{Auth: jwt})
// The route is owner-only; verify an agent with the current secret passes.
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)
}
// The pre-rotation token no longer verifies.
if _, err := jwt.Verify(tok); err == nil {
t.Fatal("pre-rotation token still verifies after RotateSecret")
}
// The new secret is never echoed back in the body, nor is a short one accepted.
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)
// The previous secret stays intact after a rejected rotation.
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)
}
}
// --- 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{}) // no 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)
}
// --- restore -----------------------------------------------------------------
func TestAdminRestoreReplacesAndPreBacksUp(t *testing.T) {
t.Parallel()
dir := t.TempDir()
dbPath := newFileDB(t, dir, "cyrene.db")
backupSrc := newFileDB(t, dir, "backup.db")
// Make the backup source clearly different from the live db by appending a
// marker. Restore only copies bytes and never opens the source, so the marker
// survives into the replaced live file and proves the swap happened.
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)
// No pre-existing backups yet.
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)
}
// The current DB was snapshotted before the replace (a new backup appeared).
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))
}
// The live DB was replaced with the backup source's bytes, so it now carries
// the marker and differs from its pre-restore content.
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() // data dir
outside := t.TempDir() // a sibling directory, outside the data dir
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{}) // no 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)
}
}