summaryrefslogtreecommitdiff
path: root/internal/api/control_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/api/control_test.go')
-rw-r--r--internal/api/control_test.go297
1 files changed, 297 insertions, 0 deletions
diff --git a/internal/api/control_test.go b/internal/api/control_test.go
new file mode 100644
index 0000000..336aa52
--- /dev/null
+++ b/internal/api/control_test.go
@@ -0,0 +1,297 @@
+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)
+ }
+}