From ba7b20b7626ed1ae553be72453475768ff1fed4a Mon Sep 17 00:00:00 2001 From: Awuqing <3184394176@qq.com> Date: Sun, 19 Apr 2026 16:38:26 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B5=8B=E8=AF=95:=20=E4=B8=80=E9=94=AE?= =?UTF-8?q?=E9=83=A8=E7=BD=B2=E7=AB=AF=E5=88=B0=E7=AB=AF=E6=B5=81=E7=A8=8B?= =?UTF-8?q?=E9=9B=86=E6=88=90=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/internal/http/install_flow_test.go | 325 ++++++++++++++++++++++ 1 file changed, 325 insertions(+) create mode 100644 server/internal/http/install_flow_test.go diff --git a/server/internal/http/install_flow_test.go b/server/internal/http/install_flow_test.go new file mode 100644 index 0000000..8b51a91 --- /dev/null +++ b/server/internal/http/install_flow_test.go @@ -0,0 +1,325 @@ +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) + + router := NewRouter(RouterDependencies{ + 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:]) +}