package api import ( "encoding/json" "os" "path/filepath" "strings" "testing" "vpnem/internal/control" "vpnem/internal/models" "vpnem/internal/rules" ) func TestCanPublishNodeState(t *testing.T) { tests := []struct { name string state control.NodeState want bool }{ {name: "healthy", state: control.NodeState{BootstrapStatus: "healthy"}, want: true}, {name: "ready", state: control.NodeState{BootstrapStatus: "ready"}, want: true}, {name: "planned", state: control.NodeState{BootstrapStatus: "planned"}, want: false}, {name: "failed", state: control.NodeState{BootstrapStatus: "failed"}, want: false}, {name: "unreachable", state: control.NodeState{BootstrapStatus: "unreachable"}, want: false}, {name: "healthy services", state: control.NodeState{ BootstrapStatus: "healthy", Services: []control.ServiceStatus{{Type: "socks5", Status: "running", Port: 1080}}, Metadata: map[string]any{"healthz_http_code": 200}, }, want: true}, {name: "degraded services", state: control.NodeState{ BootstrapStatus: "healthy", Services: []control.ServiceStatus{{Type: "socks5", Status: "unknown", Port: 1080}}, Metadata: map[string]any{"healthz_http_code": 503}, }, want: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := canPublishNodeState(tt.state) if got != tt.want { t.Fatalf("canPublishNodeState(%+v) = %v, want %v", tt.state, got, tt.want) } }) } } func setupControlTestStore(t *testing.T) *rules.Store { t.Helper() dir := t.TempDir() writeJSON := func(name string, value any) { t.Helper() data, err := json.Marshal(value) if err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, name), data, 0o600); err != nil { t.Fatal(err) } } writeJSON("servers.json", models.ServersResponse{Servers: []models.Server{}}) writeJSON("rulesets.json", models.RuleSetManifest{RuleSets: []models.RuleSet{}}) writeJSON("version.json", models.VersionResponse{Version: "test"}) writeJSON("routing-policy.json", models.RoutingPolicy{Version: "test"}) if err := os.MkdirAll(filepath.Join(dir, "control", "inventory"), 0o755); err != nil { t.Fatal(err) } if err := os.MkdirAll(filepath.Join(dir, "control", "state"), 0o755); err != nil { t.Fatal(err) } return rules.NewStore(dir) } func TestBuildQuickProvisionNode(t *testing.T) { node, password, err := buildQuickProvisionNode(quickProvisionRequest{ Host: "89.124.96.166", RootPassword: "secret", EnableMulti: true, EnableSocks: true, }) if err != nil { t.Fatalf("buildQuickProvisionNode() error = %v", err) } if password != "secret" { t.Fatalf("password = %q, want secret", password) } if node.Host != "89.124.96.166" { t.Fatalf("node.Host = %q", node.Host) } if !strings.Contains(node.Name, "Multi") { t.Fatalf("node.Name = %q, want generated multi-style name", node.Name) } if node.SSH.Auth != "password" { t.Fatalf("node.SSH.Auth = %q, want password", node.SSH.Auth) } if node.SSH.PasswordEnv == "" { t.Fatal("node.SSH.PasswordEnv should be set for persisted quick-provision nodes") } if node.SSH.Password != "secret" { t.Fatalf("node.SSH.Password mismatch") } if len(node.Protocols) != 3 { t.Fatalf("expected 3 protocols, got %d", len(node.Protocols)) } seen := map[string]int{} for _, protocol := range node.Protocols { seen[protocol.Type] = protocol.Port } if seen["vless-reality"] != 443 { t.Fatalf("vless-reality port = %d, want 443", seen["vless-reality"]) } if seen["hysteria2"] != 443 { t.Fatalf("hysteria2 port = %d, want 443", seen["hysteria2"]) } if seen["socks5"] != 54101 { t.Fatalf("socks5 port = %d, want 54101", seen["socks5"]) } } func TestBuildQuickProvisionNodeReality(t *testing.T) { node, _, err := buildQuickProvisionNode(quickProvisionRequest{ Host: "89.124.96.166", RootPassword: "secret", EnableReality: true, }) if err != nil { t.Fatalf("buildQuickProvisionNode() error = %v", err) } if len(node.Protocols) != 1 { t.Fatalf("expected 1 protocol, got %d", len(node.Protocols)) } if node.Protocols[0].Type != "vless-reality" { t.Fatalf("protocol type = %q, want vless-reality", node.Protocols[0].Type) } if node.Protocols[0].Reality == nil || node.Protocols[0].Reality.ServerName == "" { t.Fatal("expected reality defaults to be set") } } func TestNodeNeedsProvisionedDNS(t *testing.T) { realityOnly := control.Node{ Protocols: []control.ProtocolProfile{ {Type: "vless-reality", Enabled: true, Port: 443}, }, } if nodeNeedsProvisionedDNS(realityOnly) { t.Fatal("did not expect DNS requirement for vless-reality-only node") } wsNode := control.Node{ Protocols: []control.ProtocolProfile{ {Type: "vless", Enabled: true, Port: 443, TLS: &control.TLSProfile{Enabled: true}}, }, } if !nodeNeedsProvisionedDNS(wsNode) { t.Fatal("expected DNS requirement for tls-enabled vless node") } } func TestVPNUIIncludesReinstallActions(t *testing.T) { if !strings.Contains(vpnUIHTML, "Начать установку") { t.Fatal("expected installer-style quick action in vpnui") } if !strings.Contains(vpnUIHTML, "Открыть тонкую настройку") { t.Fatal("expected advanced jump action in vpnui") } if !strings.Contains(vpnUIHTML, "Быстрая установка") { t.Fatal("expected installer-like quick install heading in vpnui") } if !strings.Contains(vpnUIHTML, "Тонкая настройка и сервисные действия") { t.Fatal("expected unified advanced section in vpnui") } if !strings.Contains(vpnUIHTML, "Что заполнится автоматически") { t.Fatal("expected auto defaults explanation in vpnui") } if !strings.Contains(vpnUIHTML, "Починить сервер") { t.Fatal("expected russian repair action in vpnui") } if !strings.Contains(vpnUIHTML, "Переустановить сервер") { t.Fatal("expected russian reinstall action in vpnui") } if !strings.Contains(vpnUIHTML, "Проверить VPS") { t.Fatal("expected russian inspect vps action in vpnui") } if !strings.Contains(vpnUIHTML, "Добавить SOCKS5") { t.Fatal("expected russian Add SOCKS5 action in vpnui") } if !strings.Contains(vpnUIHTML, "Удалить сервер") { t.Fatal("expected russian delete server action in vpnui") } if !strings.Contains(vpnUIHTML, "Основные действия") { t.Fatal("expected russian primary actions section in vpnui") } if !strings.Contains(vpnUIHTML, "Ручные переопределения протоколов") { t.Fatal("expected russian operator protocol overrides section in vpnui") } if !strings.Contains(vpnUIHTML, "Что можно сделать здесь") { t.Fatal("expected russian guide in vpnui") } if !strings.Contains(vpnUIHTML, "Выберите узел, чтобы увидеть самый безопасный следующий шаг.") { t.Fatal("expected russian node guide placeholder in vpnui") } if !strings.Contains(vpnUIHTML, "Можно ставить MULTI") { t.Fatal("expected russian quick status rail labels in vpnui") } if !strings.Contains(vpnUIHTML, "Готов к публикации") { t.Fatal("expected russian node status rail labels in vpnui") } if !strings.Contains(vpnUIHTML, "data-fleet-filter=\"ready\"") { t.Fatal("expected node fleet filters in vpnui") } if !strings.Contains(vpnUIHTML, "Сейчас ни один узел не подходит под этот фильтр.") { t.Fatal("expected russian filtered empty state in vpnui") } if !strings.Contains(vpnUIHTML, "Копировать URI") { t.Fatal("expected russian copy uri action in vpnui") } if !strings.Contains(vpnUIHTML, "Копировать детали") { t.Fatal("expected russian copy details action in vpnui") } if !strings.Contains(vpnUIHTML, "Сейчас в системе") { t.Fatal("expected simplified current system summary in vpnui") } if !strings.Contains(vpnUIHTML, "Сервер работает") { t.Fatal("expected product-oriented node card language in vpnui") } } func TestFindNodeByHost(t *testing.T) { store := setupControlTestStore(t) handler := &Handler{store: store} if _, err := control.SaveNodeFile(filepath.Join(store.DataDir(), "control", "inventory"), control.Node{ ID: "nl-01", Name: "NL 01", Provider: "custom-vps", Region: "nl", Host: "89.124.96.166", Enabled: true, SSH: control.SSHConfig{User: "root", Port: 22, Auth: "key", IdentityFile: "~/.ssh/id_ed25519"}, Protocols: []control.ProtocolProfile{ {Type: "socks5", Enabled: true, Port: 54101}, }, }); err != nil { t.Fatal(err) } node, err := handler.findNodeByHost("89.124.96.166") if err != nil { t.Fatalf("findNodeByHost() error = %v", err) } if node == nil || node.ID != "nl-01" { t.Fatalf("findNodeByHost() = %+v, want nl-01", node) } } func TestBuildQuickPreflightResponse(t *testing.T) { resp := buildQuickPreflightResponse("89.124.96.166", map[string]string{ "OS_ID": "ubuntu", "OS_PRETTY": "Ubuntu 24.04 LTS", "MANAGED": "0", "DOCKER": "1", "COMPOSE": "1", "TCP_443": "0", "UDP_443": "1", "TCP_54101": "0", }) if resp.SupportTier != "recommended" { t.Fatalf("SupportTier = %q, want recommended", resp.SupportTier) } if resp.QuickMulti.Supported { t.Fatal("expected quick multi to be blocked by busy UDP 443") } if resp.QuickSocks5.Supported != true { t.Fatal("expected quick socks5 to stay supported") } if got := resp.Ports["udp_443"]; got != "busy" { t.Fatalf("udp_443 = %q, want busy", got) } if len(resp.Capabilities) < 2 || resp.Capabilities[0] != "Можно ставить SOCKS5" || resp.Capabilities[1] != "Конфликт портов для MULTI" { t.Fatalf("Capabilities = %v, want russian socks5 + multi conflict labels", resp.Capabilities) } if resp.HostStateLabel != "Можно поставить SOCKS5" { t.Fatalf("HostStateLabel = %q, want Russian SOCKS5-only state", resp.HostStateLabel) } if resp.SuggestedMultiName == "" || resp.SuggestedSocksName == "" { t.Fatalf("expected suggested names to be generated, got multi=%q socks=%q", resp.SuggestedMultiName, resp.SuggestedSocksName) } if !strings.Contains(resp.RecommendedAction, "SOCKS5") { t.Fatalf("RecommendedAction = %q, want SOCKS5 hint", resp.RecommendedAction) } }