Files
BackupX/server/internal/http/install_flow_test.go
2026-04-19 17:25:34 +08:00

332 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 http
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"backupx/server/internal/config"
"backupx/server/internal/database"
"backupx/server/internal/logger"
"backupx/server/internal/repository"
"backupx/server/internal/security"
"backupx/server/internal/service"
)
// setupInstallFlowRouter 构造一个 Node + Agent + InstallToken 全量依赖的 router
// 并返回已登录管理员 JWT。
func setupInstallFlowRouter(t *testing.T) (http.Handler, string) {
t.Helper()
tempDir := t.TempDir()
cfg := config.Config{
Server: config.ServerConfig{Host: "127.0.0.1", Port: 8340, Mode: "test"},
Database: config.DatabaseConfig{Path: filepath.Join(tempDir, "backupx.db")},
Security: config.SecurityConfig{JWTExpire: "24h"},
Log: config.LogConfig{Level: "error"},
}
log, err := logger.New(cfg.Log)
if err != nil {
t.Fatalf("logger: %v", err)
}
db, err := database.Open(cfg.Database, log)
if err != nil {
t.Fatalf("db: %v", err)
}
userRepo := repository.NewUserRepository(db)
systemConfigRepo := repository.NewSystemConfigRepository(db)
resolved, err := service.ResolveSecurity(context.Background(), cfg.Security, systemConfigRepo)
if err != nil {
t.Fatalf("security: %v", err)
}
jwtMgr := security.NewJWTManager(resolved.JWTSecret, time.Hour)
authSvc := service.NewAuthService(userRepo, systemConfigRepo, jwtMgr, security.NewLoginRateLimiter(5, time.Minute))
systemSvc := service.NewSystemService(cfg, "test", time.Now().UTC())
nodeRepo := repository.NewNodeRepository(db)
nodeSvc := service.NewNodeService(nodeRepo, "test")
if err := nodeSvc.EnsureLocalNode(context.Background()); err != nil {
t.Fatalf("ensure local: %v", err)
}
installTokenRepo := repository.NewAgentInstallTokenRepository(db)
installTokenSvc := service.NewInstallTokenService(installTokenRepo, nodeRepo)
auditLogRepo := repository.NewAuditLogRepository(db)
auditSvc := service.NewAuditService(auditLogRepo)
// 用 cancelable ctx测试结束时停掉 handler 启动的后台 GC 协程,
// 避免 goroutine 持有 map 导致 tempdir 清理失败。
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
router := NewRouter(RouterDependencies{
Context: ctx,
Config: cfg,
Version: "test",
Logger: log,
AuthService: authSvc,
SystemService: systemSvc,
NodeService: nodeSvc,
InstallTokenService: installTokenSvc,
AuditService: auditSvc,
JWTManager: jwtMgr,
UserRepository: userRepo,
SystemConfigRepo: systemConfigRepo,
})
// setup 管理员并登录拿 JWT
setupBody, _ := json.Marshal(map[string]string{
"username": "admin", "password": "password-123", "displayName": "admin",
})
setupReq := httptest.NewRequest(http.MethodPost, "/api/auth/setup", bytes.NewBuffer(setupBody))
setupReq.Header.Set("Content-Type", "application/json")
setupRec := httptest.NewRecorder()
router.ServeHTTP(setupRec, setupReq)
if setupRec.Code != 200 {
t.Fatalf("setup failed: %d %s", setupRec.Code, setupRec.Body.String())
}
var setupResp struct {
Data struct {
Token string `json:"token"`
} `json:"data"`
}
if err := json.Unmarshal(setupRec.Body.Bytes(), &setupResp); err != nil {
t.Fatalf("unmarshal setup: %v", err)
}
return router, setupResp.Data.Token
}
func TestOneClickInstallFlow(t *testing.T) {
router, jwt := setupInstallFlowRouter(t)
// 1. 批量创建
batchBody, _ := json.Marshal(map[string][]string{"names": {"prod-a", "prod-b"}})
batchReq := httptest.NewRequest(http.MethodPost, "/api/nodes/batch", bytes.NewBuffer(batchBody))
batchReq.Header.Set("Content-Type", "application/json")
batchReq.Header.Set("Authorization", "Bearer "+jwt)
batchRec := httptest.NewRecorder()
router.ServeHTTP(batchRec, batchReq)
if batchRec.Code != 200 {
t.Fatalf("batch create failed: %d %s", batchRec.Code, batchRec.Body.String())
}
var batchResp struct {
Data []struct {
ID uint `json:"id"`
Name string `json:"name"`
} `json:"data"`
}
if err := json.Unmarshal(batchRec.Body.Bytes(), &batchResp); err != nil {
t.Fatalf("unmarshal batch: %v", err)
}
if len(batchResp.Data) != 2 {
t.Fatalf("expected 2 nodes, got %d", len(batchResp.Data))
}
nodeID := batchResp.Data[0].ID
// 2. 生成 install token
genBody, _ := json.Marshal(map[string]any{
"mode": "systemd",
"arch": "auto",
"agentVersion": "v1.7.0",
"downloadSrc": "github",
"ttlSeconds": 900,
})
genReq := httptest.NewRequest(http.MethodPost,
"/api/nodes/"+formatUint(nodeID)+"/install-tokens", bytes.NewBuffer(genBody))
genReq.Header.Set("Content-Type", "application/json")
genReq.Header.Set("Authorization", "Bearer "+jwt)
genRec := httptest.NewRecorder()
router.ServeHTTP(genRec, genReq)
if genRec.Code != 200 {
t.Fatalf("install-tokens failed: %d %s", genRec.Code, genRec.Body.String())
}
var genResp struct {
Data struct {
InstallToken string `json:"installToken"`
URL string `json:"url"`
} `json:"data"`
}
if err := json.Unmarshal(genRec.Body.Bytes(), &genResp); err != nil {
t.Fatalf("unmarshal gen: %v", err)
}
if genResp.Data.InstallToken == "" {
t.Fatalf("missing installToken")
}
// 3. 公开端点消费
scriptReq := httptest.NewRequest(http.MethodGet, "/install/"+genResp.Data.InstallToken, nil)
scriptRec := httptest.NewRecorder()
router.ServeHTTP(scriptRec, scriptReq)
if scriptRec.Code != 200 {
t.Fatalf("script fetch failed: %d %s", scriptRec.Code, scriptRec.Body.String())
}
if !strings.Contains(scriptRec.Body.String(), "systemctl enable --now backupx-agent") {
t.Fatalf("script missing systemctl enable:\n%s", scriptRec.Body.String())
}
// 4. 再次消费应 410
scriptReq2 := httptest.NewRequest(http.MethodGet, "/install/"+genResp.Data.InstallToken, nil)
scriptRec2 := httptest.NewRecorder()
router.ServeHTTP(scriptRec2, scriptReq2)
if scriptRec2.Code != http.StatusGone {
t.Fatalf("second consume should be 410, got %d: %s", scriptRec2.Code, scriptRec2.Body.String())
}
}
func TestInstallTokenRateLimit(t *testing.T) {
router, jwt := setupInstallFlowRouter(t)
batchBody, _ := json.Marshal(map[string][]string{"names": {"rl-test"}})
batchReq := httptest.NewRequest(http.MethodPost, "/api/nodes/batch", bytes.NewBuffer(batchBody))
batchReq.Header.Set("Content-Type", "application/json")
batchReq.Header.Set("Authorization", "Bearer "+jwt)
batchRec := httptest.NewRecorder()
router.ServeHTTP(batchRec, batchReq)
if batchRec.Code != 200 {
t.Fatalf("batch: %d %s", batchRec.Code, batchRec.Body.String())
}
var batchResp struct {
Data []struct {
ID uint `json:"id"`
} `json:"data"`
}
_ = json.Unmarshal(batchRec.Body.Bytes(), &batchResp)
nodeID := batchResp.Data[0].ID
body, _ := json.Marshal(map[string]any{
"mode": "systemd", "arch": "auto", "agentVersion": "v1",
"downloadSrc": "github", "ttlSeconds": 300,
})
for i := 0; i < 5; i++ {
req := httptest.NewRequest(http.MethodPost,
"/api/nodes/"+formatUint(nodeID)+"/install-tokens", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+jwt)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("iter %d expected 200, got %d: %s", i, rec.Code, rec.Body.String())
}
}
req := httptest.NewRequest(http.MethodPost,
"/api/nodes/"+formatUint(nodeID)+"/install-tokens", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+jwt)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("expected 429, got %d: %s", rec.Code, rec.Body.String())
}
}
func TestRotateTokenFlow(t *testing.T) {
router, jwt := setupInstallFlowRouter(t)
batchBody, _ := json.Marshal(map[string][]string{"names": {"rot-x"}})
batchReq := httptest.NewRequest(http.MethodPost, "/api/nodes/batch", bytes.NewBuffer(batchBody))
batchReq.Header.Set("Content-Type", "application/json")
batchReq.Header.Set("Authorization", "Bearer "+jwt)
batchRec := httptest.NewRecorder()
router.ServeHTTP(batchRec, batchReq)
var batchResp struct {
Data []struct {
ID uint `json:"id"`
} `json:"data"`
}
_ = json.Unmarshal(batchRec.Body.Bytes(), &batchResp)
nodeID := batchResp.Data[0].ID
rotReq := httptest.NewRequest(http.MethodPost,
"/api/nodes/"+formatUint(nodeID)+"/rotate-token", nil)
rotReq.Header.Set("Authorization", "Bearer "+jwt)
rotRec := httptest.NewRecorder()
router.ServeHTTP(rotRec, rotReq)
if rotRec.Code != 200 {
t.Fatalf("rotate failed: %d %s", rotRec.Code, rotRec.Body.String())
}
var rotResp struct {
Data struct {
NewToken string `json:"newToken"`
} `json:"data"`
}
_ = json.Unmarshal(rotRec.Body.Bytes(), &rotResp)
if len(rotResp.Data.NewToken) != 64 {
t.Fatalf("new token wrong length: %s", rotResp.Data.NewToken)
}
}
func TestInstallFlowComposeModeMismatch(t *testing.T) {
router, jwt := setupInstallFlowRouter(t)
batchBody, _ := json.Marshal(map[string][]string{"names": {"cm"}})
batchReq := httptest.NewRequest(http.MethodPost, "/api/nodes/batch", bytes.NewBuffer(batchBody))
batchReq.Header.Set("Content-Type", "application/json")
batchReq.Header.Set("Authorization", "Bearer "+jwt)
batchRec := httptest.NewRecorder()
router.ServeHTTP(batchRec, batchReq)
var batchResp struct {
Data []struct {
ID uint `json:"id"`
} `json:"data"`
}
_ = json.Unmarshal(batchRec.Body.Bytes(), &batchResp)
nodeID := batchResp.Data[0].ID
// 生成 systemd 模式的 token
genBody, _ := json.Marshal(map[string]any{
"mode": "systemd", "arch": "auto", "agentVersion": "v1",
"downloadSrc": "github", "ttlSeconds": 300,
})
genReq := httptest.NewRequest(http.MethodPost,
"/api/nodes/"+formatUint(nodeID)+"/install-tokens", bytes.NewBuffer(genBody))
genReq.Header.Set("Content-Type", "application/json")
genReq.Header.Set("Authorization", "Bearer "+jwt)
genRec := httptest.NewRecorder()
router.ServeHTTP(genRec, genReq)
var genResp struct {
Data struct {
InstallToken string `json:"installToken"`
} `json:"data"`
}
_ = json.Unmarshal(genRec.Body.Bytes(), &genResp)
// 访问 compose.yml 应 400
req := httptest.NewRequest(http.MethodGet,
"/install/"+genResp.Data.InstallToken+"/compose.yml", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for mode mismatch, got %d: %s", rec.Code, rec.Body.String())
}
// systemd token 未被消费Peek 不消费)→ 应仍可通过 /install/:token 消费成功
req2 := httptest.NewRequest(http.MethodGet, "/install/"+genResp.Data.InstallToken, nil)
rec2 := httptest.NewRecorder()
router.ServeHTTP(rec2, req2)
if rec2.Code != 200 {
t.Fatalf("original script fetch should still work: %d %s", rec2.Code, rec2.Body.String())
}
}
// formatUint 小工具uint → 十进制字符串(无需引入 strconv
func formatUint(u uint) string {
if u == 0 {
return "0"
}
var buf [20]byte
i := len(buf)
for u > 0 {
i--
buf[i] = byte('0' + u%10)
u /= 10
}
return string(buf[i:])
}