mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-06-02 00:09:45 +08:00
功能: 一键部署 Agent 向导 (#44)
This commit is contained in:
@@ -346,3 +346,24 @@ func (s *AgentService) StartCommandTimeoutMonitor(ctx context.Context, interval
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// AgentSelfStatus 是 /api/v1/agent/self 端点返回给 Agent 的轻量状态摘要。
|
||||
type AgentSelfStatus struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
LastSeen time.Time `json:"lastSeen"`
|
||||
}
|
||||
|
||||
// SelfStatus 返回 Agent token 所属节点的当前状态,供安装脚本末尾探活。
|
||||
func (s *AgentService) SelfStatus(ctx context.Context, node *model.Node) (*AgentSelfStatus, error) {
|
||||
if node == nil {
|
||||
return nil, apperror.Unauthorized("NODE_INVALID_TOKEN", "节点不存在", nil)
|
||||
}
|
||||
return &AgentSelfStatus{
|
||||
ID: node.ID,
|
||||
Name: node.Name,
|
||||
Status: node.Status,
|
||||
LastSeen: node.LastSeen,
|
||||
}, nil
|
||||
}
|
||||
|
||||
189
server/internal/service/install_token_service.go
Normal file
189
server/internal/service/install_token_service.go
Normal file
@@ -0,0 +1,189 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/apperror"
|
||||
"backupx/server/internal/model"
|
||||
"backupx/server/internal/repository"
|
||||
)
|
||||
|
||||
// InstallTokenService 负责一次性安装令牌的创建/消费/校验。
|
||||
type InstallTokenService struct {
|
||||
repo repository.AgentInstallTokenRepository
|
||||
nodeRepo repository.NodeRepository
|
||||
}
|
||||
|
||||
func NewInstallTokenService(repo repository.AgentInstallTokenRepository, nodeRepo repository.NodeRepository) *InstallTokenService {
|
||||
return &InstallTokenService{repo: repo, nodeRepo: nodeRepo}
|
||||
}
|
||||
|
||||
// InstallTokenInput 生成一次性安装令牌的输入。
|
||||
type InstallTokenInput struct {
|
||||
NodeID uint
|
||||
Mode string
|
||||
Arch string
|
||||
AgentVersion string
|
||||
DownloadSrc string
|
||||
TTLSeconds int
|
||||
CreatedByID uint
|
||||
}
|
||||
|
||||
// InstallTokenOutput 生成结果。
|
||||
type InstallTokenOutput struct {
|
||||
Token string
|
||||
ExpiresAt time.Time
|
||||
Node *model.Node
|
||||
Record *model.AgentInstallToken
|
||||
}
|
||||
|
||||
// ConsumedInstallToken 消费成功后返回给 handler 的组合体。
|
||||
type ConsumedInstallToken struct {
|
||||
Record *model.AgentInstallToken
|
||||
Node *model.Node
|
||||
}
|
||||
|
||||
// 校验与限流常量。
|
||||
const (
|
||||
InstallTokenMinTTL = 300 // 5 分钟
|
||||
InstallTokenMaxTTL = 86400 // 24 小时
|
||||
InstallTokenRateWindow = 60 * time.Second
|
||||
InstallTokenRatePerWin = 5
|
||||
)
|
||||
|
||||
var (
|
||||
validInstallModes = map[string]bool{model.InstallModeSystemd: true, model.InstallModeDocker: true, model.InstallModeForeground: true}
|
||||
validInstallArches = map[string]bool{model.InstallArchAmd64: true, model.InstallArchArm64: true, model.InstallArchAuto: true}
|
||||
validInstallSources = map[string]bool{model.InstallSourceGitHub: true, model.InstallSourceGhproxy: true}
|
||||
)
|
||||
|
||||
// Create 生成一次性安装令牌。
|
||||
func (s *InstallTokenService) Create(ctx context.Context, in InstallTokenInput) (*InstallTokenOutput, error) {
|
||||
if err := s.validate(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
node, err := s.nodeRepo.FindByID(ctx, in.NodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if node == nil {
|
||||
return nil, apperror.New(404, "NODE_NOT_FOUND", "节点不存在", nil)
|
||||
}
|
||||
|
||||
since := time.Now().UTC().Add(-InstallTokenRateWindow)
|
||||
count, err := s.repo.CountCreatedSince(ctx, in.NodeID, since)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count >= InstallTokenRatePerWin {
|
||||
return nil, apperror.TooManyRequests("INSTALL_TOKEN_RATE_LIMITED",
|
||||
fmt.Sprintf("每 %d 秒最多生成 %d 次", int(InstallTokenRateWindow.Seconds()), InstallTokenRatePerWin), nil)
|
||||
}
|
||||
|
||||
token, err := generateInstallToken()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate token: %w", err)
|
||||
}
|
||||
expiresAt := time.Now().UTC().Add(time.Duration(in.TTLSeconds) * time.Second)
|
||||
record := &model.AgentInstallToken{
|
||||
Token: token,
|
||||
NodeID: in.NodeID,
|
||||
Mode: in.Mode,
|
||||
Arch: in.Arch,
|
||||
AgentVer: in.AgentVersion,
|
||||
DownloadSrc: in.DownloadSrc,
|
||||
ExpiresAt: expiresAt,
|
||||
CreatedByID: in.CreatedByID,
|
||||
}
|
||||
if err := s.repo.Create(ctx, record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &InstallTokenOutput{Token: token, ExpiresAt: expiresAt, Node: node, Record: record}, nil
|
||||
}
|
||||
|
||||
// Consume 原子消费令牌。未命中/已过期/已消费均返回 (nil, nil)。
|
||||
func (s *InstallTokenService) Consume(ctx context.Context, token string) (*ConsumedInstallToken, error) {
|
||||
if strings.TrimSpace(token) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
record, err := s.repo.ConsumeByToken(ctx, token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if record == nil {
|
||||
return nil, nil
|
||||
}
|
||||
node, err := s.nodeRepo.FindByID(ctx, record.NodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if node == nil {
|
||||
return nil, apperror.New(404, "NODE_NOT_FOUND", "节点已被删除", nil)
|
||||
}
|
||||
return &ConsumedInstallToken{Record: record, Node: node}, nil
|
||||
}
|
||||
|
||||
// Peek 只读查询(不消费)且仅返回有效 token(未消费、未过期),供 compose 端点预检 Mode。
|
||||
// 对已过期/已消费的 token 返回 (nil, nil),与 Consume 语义保持一致,
|
||||
// 避免 compose handler 误放行"僵尸 token"造成后续 Consume 必然失败的迷惑链路。
|
||||
func (s *InstallTokenService) Peek(ctx context.Context, token string) (*model.AgentInstallToken, error) {
|
||||
if strings.TrimSpace(token) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return s.repo.FindValidByToken(ctx, token)
|
||||
}
|
||||
|
||||
// StartGC 启动后台 GC,按 interval 扫描并删 ExpiresAt < now-7d 的记录。
|
||||
func (s *InstallTokenService) StartGC(ctx context.Context, interval time.Duration) {
|
||||
if interval <= 0 {
|
||||
interval = time.Hour
|
||||
}
|
||||
go func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
_, _ = s.repo.DeleteExpiredBefore(ctx, time.Now().UTC().Add(-7*24*time.Hour))
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *InstallTokenService) validate(in InstallTokenInput) error {
|
||||
if in.NodeID == 0 {
|
||||
return apperror.BadRequest("INSTALL_TOKEN_INVALID", "nodeId 必填", nil)
|
||||
}
|
||||
if !validInstallModes[in.Mode] {
|
||||
return apperror.BadRequest("INSTALL_TOKEN_INVALID", "mode 非法", nil)
|
||||
}
|
||||
if !validInstallArches[in.Arch] {
|
||||
return apperror.BadRequest("INSTALL_TOKEN_INVALID", "arch 非法", nil)
|
||||
}
|
||||
if !validInstallSources[in.DownloadSrc] {
|
||||
return apperror.BadRequest("INSTALL_TOKEN_INVALID", "downloadSrc 非法", nil)
|
||||
}
|
||||
if strings.TrimSpace(in.AgentVersion) == "" {
|
||||
return apperror.BadRequest("INSTALL_TOKEN_INVALID", "agentVersion 必填", nil)
|
||||
}
|
||||
if in.TTLSeconds < InstallTokenMinTTL || in.TTLSeconds > InstallTokenMaxTTL {
|
||||
return apperror.BadRequest("INSTALL_TOKEN_INVALID",
|
||||
fmt.Sprintf("ttlSeconds 需在 %d-%d", InstallTokenMinTTL, InstallTokenMaxTTL), nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func generateInstallToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
156
server/internal/service/install_token_service_test.go
Normal file
156
server/internal/service/install_token_service_test.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/model"
|
||||
"backupx/server/internal/repository"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
gormlogger "gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func openInstallTokenTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "it.db")),
|
||||
&gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)})
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.AgentInstallToken{}, &model.Node{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestInstallTokenServiceCreateAndConsume(t *testing.T) {
|
||||
db := openInstallTokenTestDB(t)
|
||||
repo := repository.NewAgentInstallTokenRepository(db)
|
||||
nodeRepo := repository.NewNodeRepository(db)
|
||||
|
||||
node := &model.Node{Name: "n1", Token: "agent-token"}
|
||||
if err := nodeRepo.Create(context.Background(), node); err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
|
||||
svc := NewInstallTokenService(repo, nodeRepo)
|
||||
created, err := svc.Create(context.Background(), InstallTokenInput{
|
||||
NodeID: node.ID,
|
||||
Mode: model.InstallModeSystemd,
|
||||
Arch: model.InstallArchAuto,
|
||||
AgentVersion: "v1.7.0",
|
||||
DownloadSrc: model.InstallSourceGitHub,
|
||||
TTLSeconds: 900,
|
||||
CreatedByID: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if created.Token == "" || created.ExpiresAt.Before(time.Now().UTC()) {
|
||||
t.Fatalf("invalid token: %+v", created)
|
||||
}
|
||||
|
||||
consumed, err := svc.Consume(context.Background(), created.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("consume: %v", err)
|
||||
}
|
||||
if consumed == nil || consumed.Node.ID != node.ID {
|
||||
t.Fatalf("expected consumed token for node, got %+v", consumed)
|
||||
}
|
||||
|
||||
again, err := svc.Consume(context.Background(), created.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("second consume err: %v", err)
|
||||
}
|
||||
if again != nil {
|
||||
t.Fatalf("expected nil on second consume")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallTokenServicePeekDoesNotConsume(t *testing.T) {
|
||||
db := openInstallTokenTestDB(t)
|
||||
repo := repository.NewAgentInstallTokenRepository(db)
|
||||
nodeRepo := repository.NewNodeRepository(db)
|
||||
node := &model.Node{Name: "n2", Token: "tok2"}
|
||||
_ = nodeRepo.Create(context.Background(), node)
|
||||
|
||||
svc := NewInstallTokenService(repo, nodeRepo)
|
||||
out, err := svc.Create(context.Background(), InstallTokenInput{
|
||||
NodeID: node.ID, Mode: "docker", Arch: "auto",
|
||||
AgentVersion: "v1", DownloadSrc: "github", TTLSeconds: 300, CreatedByID: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
// Peek 两次都应成功(不消费)
|
||||
for i := 0; i < 2; i++ {
|
||||
rec, err := svc.Peek(context.Background(), out.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("peek %d: %v", i, err)
|
||||
}
|
||||
if rec == nil || rec.Mode != "docker" {
|
||||
t.Fatalf("peek %d bad: %+v", i, rec)
|
||||
}
|
||||
}
|
||||
|
||||
// 之后仍可消费
|
||||
consumed, _ := svc.Consume(context.Background(), out.Token)
|
||||
if consumed == nil {
|
||||
t.Fatalf("consume after peek failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallTokenServiceValidatesInput(t *testing.T) {
|
||||
db := openInstallTokenTestDB(t)
|
||||
nodeRepo := repository.NewNodeRepository(db)
|
||||
node := &model.Node{Name: "valid", Token: "t"}
|
||||
_ = nodeRepo.Create(context.Background(), node)
|
||||
|
||||
svc := NewInstallTokenService(repository.NewAgentInstallTokenRepository(db), nodeRepo)
|
||||
cases := []struct {
|
||||
name string
|
||||
in InstallTokenInput
|
||||
}{
|
||||
{"bad mode", InstallTokenInput{NodeID: node.ID, Mode: "xxx", Arch: "auto", AgentVersion: "v1", DownloadSrc: "github", TTLSeconds: 300, CreatedByID: 1}},
|
||||
{"bad arch", InstallTokenInput{NodeID: node.ID, Mode: "systemd", Arch: "risc", AgentVersion: "v1", DownloadSrc: "github", TTLSeconds: 300, CreatedByID: 1}},
|
||||
{"bad source", InstallTokenInput{NodeID: node.ID, Mode: "systemd", Arch: "auto", AgentVersion: "v1", DownloadSrc: "bogus", TTLSeconds: 300, CreatedByID: 1}},
|
||||
{"bad ttl low", InstallTokenInput{NodeID: node.ID, Mode: "systemd", Arch: "auto", AgentVersion: "v1", DownloadSrc: "github", TTLSeconds: 10, CreatedByID: 1}},
|
||||
{"bad ttl high", InstallTokenInput{NodeID: node.ID, Mode: "systemd", Arch: "auto", AgentVersion: "v1", DownloadSrc: "github", TTLSeconds: 999999, CreatedByID: 1}},
|
||||
{"missing version", InstallTokenInput{NodeID: node.ID, Mode: "systemd", Arch: "auto", AgentVersion: "", DownloadSrc: "github", TTLSeconds: 300, CreatedByID: 1}},
|
||||
{"missing node id", InstallTokenInput{NodeID: 0, Mode: "systemd", Arch: "auto", AgentVersion: "v1", DownloadSrc: "github", TTLSeconds: 300, CreatedByID: 1}},
|
||||
{"node not exists", InstallTokenInput{NodeID: 999, Mode: "systemd", Arch: "auto", AgentVersion: "v1", DownloadSrc: "github", TTLSeconds: 300, CreatedByID: 1}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if _, err := svc.Create(context.Background(), tc.in); err == nil {
|
||||
t.Errorf("%s: expected validation error", tc.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallTokenServiceRateLimit(t *testing.T) {
|
||||
db := openInstallTokenTestDB(t)
|
||||
nodeRepo := repository.NewNodeRepository(db)
|
||||
node := &model.Node{Name: "rl", Token: "rl"}
|
||||
_ = nodeRepo.Create(context.Background(), node)
|
||||
|
||||
svc := NewInstallTokenService(repository.NewAgentInstallTokenRepository(db), nodeRepo)
|
||||
base := InstallTokenInput{
|
||||
NodeID: node.ID, Mode: "systemd", Arch: "auto",
|
||||
AgentVersion: "v1", DownloadSrc: "github", TTLSeconds: 300, CreatedByID: 1,
|
||||
}
|
||||
// 前 5 次成功
|
||||
for i := 0; i < 5; i++ {
|
||||
if _, err := svc.Create(context.Background(), base); err != nil {
|
||||
t.Fatalf("iter %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
// 第 6 次应被限流
|
||||
_, err := svc.Create(context.Background(), base)
|
||||
if err == nil {
|
||||
t.Fatalf("expected rate limit error")
|
||||
}
|
||||
}
|
||||
@@ -373,6 +373,121 @@ func detectLocalIP() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// NodeCreateResult 批量创建结果。注意:不暴露 agent token,token 获取走 install-token 流程。
|
||||
type NodeCreateResult struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// BatchCreate 批量创建远程节点。
|
||||
// 校验:1-50 项、每项 1-128 字符、批次内去重、与已有节点名去重。
|
||||
// 返回 NodeCreateResult 列表(不含 token,调用方应再调 install-tokens 接口)。
|
||||
func (s *NodeService) BatchCreate(ctx context.Context, names []string) ([]NodeCreateResult, error) {
|
||||
cleaned, err := validateBatchNames(names)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
existing, err := s.repo.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
existingSet := make(map[string]bool, len(existing))
|
||||
for _, n := range existing {
|
||||
existingSet[n.Name] = true
|
||||
}
|
||||
for _, name := range cleaned {
|
||||
if existingSet[name] {
|
||||
return nil, apperror.BadRequest("NODE_DUPLICATE_NAME",
|
||||
fmt.Sprintf("节点名「%s」已存在", name), nil)
|
||||
}
|
||||
}
|
||||
|
||||
// 预先构造所有 Node,token 生成在事务外完成(纯内存操作,失败不会影响 DB 状态)
|
||||
nodes := make([]*model.Node, 0, len(cleaned))
|
||||
now := time.Now().UTC()
|
||||
for _, name := range cleaned {
|
||||
tok, err := generateToken()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate token: %w", err)
|
||||
}
|
||||
nodes = append(nodes, &model.Node{
|
||||
Name: name,
|
||||
Token: tok,
|
||||
Status: model.NodeStatusOffline,
|
||||
IsLocal: false,
|
||||
LastSeen: now,
|
||||
})
|
||||
}
|
||||
// 事务内批量创建:任一失败整体回滚
|
||||
if err := s.repo.BatchCreate(ctx, nodes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results := make([]NodeCreateResult, 0, len(nodes))
|
||||
for _, n := range nodes {
|
||||
results = append(results, NodeCreateResult{ID: n.ID, Name: n.Name})
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// RotateToken 轮换指定节点的 agent token。
|
||||
// 旧 token 复制到 prev_token,24h 内新旧 token 均可认证。
|
||||
func (s *NodeService) RotateToken(ctx context.Context, id uint) (string, error) {
|
||||
node, err := s.repo.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if node == nil {
|
||||
return "", apperror.New(http.StatusNotFound, "NODE_NOT_FOUND", "节点不存在", nil)
|
||||
}
|
||||
if node.IsLocal {
|
||||
return "", apperror.BadRequest("NODE_ROTATE_LOCAL", "本机节点无需轮换 Token", nil)
|
||||
}
|
||||
newTok, err := generateToken()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("generate: %w", err)
|
||||
}
|
||||
expires := time.Now().UTC().Add(24 * time.Hour)
|
||||
node.PrevToken = node.Token
|
||||
node.PrevTokenExpires = &expires
|
||||
node.Token = newTok
|
||||
if err := s.repo.Update(ctx, node); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return newTok, nil
|
||||
}
|
||||
|
||||
// validateBatchNames 校验并去重批次内名称(空白行忽略)。
|
||||
func validateBatchNames(names []string) ([]string, error) {
|
||||
if len(names) == 0 {
|
||||
return nil, apperror.BadRequest("NODE_BATCH_EMPTY", "节点名列表不能为空", nil)
|
||||
}
|
||||
if len(names) > 50 {
|
||||
return nil, apperror.BadRequest("NODE_BATCH_TOO_MANY", "单次最多创建 50 个节点", nil)
|
||||
}
|
||||
seen := make(map[string]bool, len(names))
|
||||
out := make([]string, 0, len(names))
|
||||
for _, raw := range names {
|
||||
name := strings.TrimSpace(raw)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if len(name) > 128 {
|
||||
return nil, apperror.BadRequest("NODE_NAME_TOO_LONG",
|
||||
fmt.Sprintf("节点名「%s」超过 128 字符", name), nil)
|
||||
}
|
||||
if seen[name] {
|
||||
return nil, apperror.BadRequest("NODE_DUPLICATE_NAME",
|
||||
fmt.Sprintf("批次内重复节点名「%s」", name), nil)
|
||||
}
|
||||
seen[name] = true
|
||||
out = append(out, name)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, apperror.BadRequest("NODE_BATCH_EMPTY", "去除空白后列表为空", nil)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func generateToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
|
||||
159
server/internal/service/node_service_test.go
Normal file
159
server/internal/service/node_service_test.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/model"
|
||||
"backupx/server/internal/repository"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
gormlogger "gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func openNodeServiceDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "ns.db")),
|
||||
&gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)})
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Node{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestBatchCreateNodes(t *testing.T) {
|
||||
db := openNodeServiceDB(t)
|
||||
svc := NewNodeService(repository.NewNodeRepository(db), "test")
|
||||
ctx := context.Background()
|
||||
|
||||
items, err := svc.BatchCreate(ctx, []string{"a", "b", "c"})
|
||||
if err != nil {
|
||||
t.Fatalf("batch: %v", err)
|
||||
}
|
||||
if len(items) != 3 {
|
||||
t.Fatalf("expected 3, got %d", len(items))
|
||||
}
|
||||
for _, it := range items {
|
||||
if it.ID == 0 || it.Name == "" {
|
||||
t.Errorf("invalid item %+v", it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateRejectsDuplicatesAgainstDB(t *testing.T) {
|
||||
db := openNodeServiceDB(t)
|
||||
svc := NewNodeService(repository.NewNodeRepository(db), "test")
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := svc.Create(ctx, NodeCreateInput{Name: "a"}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
_, err := svc.BatchCreate(ctx, []string{"a", "b"})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error on duplicate with existing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateRejectsIntraBatchDuplicates(t *testing.T) {
|
||||
db := openNodeServiceDB(t)
|
||||
svc := NewNodeService(repository.NewNodeRepository(db), "test")
|
||||
_, err := svc.BatchCreate(context.Background(), []string{"x", "x"})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error on intra-batch duplicate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateLimitEnforced(t *testing.T) {
|
||||
db := openNodeServiceDB(t)
|
||||
svc := NewNodeService(repository.NewNodeRepository(db), "test")
|
||||
names := make([]string, 51)
|
||||
for i := range names {
|
||||
names[i] = "n" + string(rune('A'+i))
|
||||
}
|
||||
_, err := svc.BatchCreate(context.Background(), names)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error on >50 batch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateSkipsEmptyLines(t *testing.T) {
|
||||
db := openNodeServiceDB(t)
|
||||
svc := NewNodeService(repository.NewNodeRepository(db), "test")
|
||||
items, err := svc.BatchCreate(context.Background(), []string{"a", " ", "", "b"})
|
||||
if err != nil {
|
||||
t.Fatalf("batch: %v", err)
|
||||
}
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("expected 2 (a,b), got %d", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRotateToken(t *testing.T) {
|
||||
db := openNodeServiceDB(t)
|
||||
repo := repository.NewNodeRepository(db)
|
||||
svc := NewNodeService(repo, "test")
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := svc.Create(ctx, NodeCreateInput{Name: "rot"})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
var node model.Node
|
||||
db.First(&node, "name = ?", "rot")
|
||||
oldTok := node.Token
|
||||
|
||||
newTok, err := svc.RotateToken(ctx, node.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("rotate: %v", err)
|
||||
}
|
||||
if newTok == oldTok || len(newTok) != 64 {
|
||||
t.Fatalf("invalid new token: %s", newTok)
|
||||
}
|
||||
|
||||
// 旧 token 仍可查(24h 内)
|
||||
found, _ := repo.FindByToken(ctx, oldTok)
|
||||
if found == nil || found.ID != node.ID {
|
||||
t.Fatalf("old token should still work via prev_token fallback")
|
||||
}
|
||||
found2, _ := repo.FindByToken(ctx, newTok)
|
||||
if found2 == nil || found2.ID != node.ID {
|
||||
t.Fatalf("new token should work")
|
||||
}
|
||||
|
||||
db.First(&node, node.ID)
|
||||
if node.PrevTokenExpires == nil {
|
||||
t.Fatalf("prev_token_expires not set")
|
||||
}
|
||||
diff := node.PrevTokenExpires.Sub(time.Now().UTC())
|
||||
if diff < 23*time.Hour || diff > 25*time.Hour {
|
||||
t.Fatalf("prev_token_expires out of range: %v", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRotateTokenRejectsLocal(t *testing.T) {
|
||||
db := openNodeServiceDB(t)
|
||||
repo := repository.NewNodeRepository(db)
|
||||
svc := NewNodeService(repo, "test")
|
||||
ctx := context.Background()
|
||||
|
||||
if err := svc.EnsureLocalNode(ctx); err != nil {
|
||||
t.Fatalf("ensure local: %v", err)
|
||||
}
|
||||
local, _ := repo.FindLocal(ctx)
|
||||
if _, err := svc.RotateToken(ctx, local.ID); err == nil {
|
||||
t.Fatalf("expected error rotating local node")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRotateTokenNotFound(t *testing.T) {
|
||||
db := openNodeServiceDB(t)
|
||||
svc := NewNodeService(repository.NewNodeRepository(db), "test")
|
||||
if _, err := svc.RotateToken(context.Background(), 9999); err == nil {
|
||||
t.Fatalf("expected not found error")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user