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密码仅登录验证不打印
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 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)
|
|
}
|
|
}
|