summaryrefslogtreecommitdiff
path: root/internal/control/catalog_test.go
diff options
context:
space:
mode:
authorsergei <sergei@em-sysadmin.xyz>2026-04-14 06:23:55 +0400
committersergei <sergei@em-sysadmin.xyz>2026-04-14 06:23:55 +0400
commit3d51aa455006903345f554a2dd90034993796114 (patch)
tree62a7be2faf047f5eb7886feebc3b815556f03d7f /internal/control/catalog_test.go
downloadvpnem-main.tar.gz
vpnem-main.tar.bz2
vpnem-main.zip
vpnem: VPN infrastructure with load-balanced multi-protocol nodesHEADmain
- Multi-protocol VPS nodes (VLESS-REALITY + Hysteria2 + SOCKS5) - Smart load balancing via recommendation API - Windows/Linux client (Go + Wails + sing-box) - Server API with RealIP detection and connection tracking - Auto-deployment via vpnui control plane - Silent Windows installer with UAC elevation - Load-based server recommendation (no sticky sessions) - Best Server one-click connection workflow
Diffstat (limited to 'internal/control/catalog_test.go')
-rw-r--r--internal/control/catalog_test.go332
1 files changed, 332 insertions, 0 deletions
diff --git a/internal/control/catalog_test.go b/internal/control/catalog_test.go
new file mode 100644
index 0000000..facaaf7
--- /dev/null
+++ b/internal/control/catalog_test.go
@@ -0,0 +1,332 @@
+package control
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "vpnem/internal/models"
+)
+
+func TestBuildLegacyCatalog(t *testing.T) {
+ t.Parallel()
+
+ nodes := []Node{
+ {
+ ID: "nl-01",
+ Name: "NL 01",
+ Region: "nl",
+ Host: "203.0.113.10",
+ Domain: "nl-01.example.com",
+ Enabled: true,
+ SSH: SSHConfig{
+ User: "root",
+ Port: 22,
+ Auth: "key",
+ },
+ Protocols: []ProtocolProfile{
+ {
+ Type: "vless",
+ Enabled: true,
+ Port: 443,
+ TLS: &TLSProfile{
+ Enabled: true,
+ ServerName: "nl-01.example.com",
+ },
+ Auth: &AuthProfile{
+ UUID: "11111111-1111-1111-1111-111111111111",
+ },
+ Extra: map[string]any{
+ "transport_type": "ws",
+ "path": "/ws",
+ },
+ },
+ {
+ Type: "shadowsocks",
+ Enabled: true,
+ Port: 8443,
+ Auth: &AuthProfile{
+ Method: "2022-blake3-aes-128-gcm",
+ Password: "secret",
+ },
+ },
+ },
+ },
+ }
+
+ resp, err := BuildLegacyCatalog(nodes)
+ if err != nil {
+ t.Fatalf("BuildLegacyCatalog error = %v", err)
+ }
+ if len(resp.Servers) != 2 {
+ t.Fatalf("len(resp.Servers) = %d, want 2", len(resp.Servers))
+ }
+ if resp.Servers[0].Tag != "nl-01-shadowsocks" {
+ t.Fatalf("unexpected first tag %q", resp.Servers[0].Tag)
+ }
+ if resp.Servers[1].Tag != "nl-01-vless" {
+ t.Fatalf("unexpected second tag %q", resp.Servers[1].Tag)
+ }
+ if resp.Servers[1].Transport == nil || resp.Servers[1].Transport.Type != "ws" {
+ t.Fatalf("expected ws transport, got %+v", resp.Servers[1].Transport)
+ }
+}
+
+func TestBuildLegacyCatalogRejectsUnsupportedProtocol(t *testing.T) {
+ t.Parallel()
+
+ _, err := BuildLegacyCatalog([]Node{
+ {
+ ID: "nl-01",
+ Name: "NL 01",
+ Region: "nl",
+ Host: "203.0.113.10",
+ Enabled: true,
+ SSH: SSHConfig{
+ User: "root",
+ Port: 22,
+ Auth: "key",
+ },
+ Protocols: []ProtocolProfile{
+ {Type: "hysteria2", Enabled: true, Port: 443},
+ },
+ },
+ })
+ if err == nil {
+ t.Fatal("expected unsupported protocol error")
+ }
+}
+
+func TestPublishableNodes(t *testing.T) {
+ t.Parallel()
+
+ nodes := []Node{
+ {ID: "healthy", Name: "healthy", Region: "nl", Host: "1.1.1.1", Enabled: true, SSH: SSHConfig{User: "root", Port: 22, Auth: "key"}, Protocols: []ProtocolProfile{{Type: "socks5", Enabled: true, Port: 1080}}},
+ {ID: "failed", Name: "failed", Region: "nl", Host: "1.1.1.2", Enabled: true, SSH: SSHConfig{User: "root", Port: 22, Auth: "key"}, Protocols: []ProtocolProfile{{Type: "socks5", Enabled: true, Port: 1080}}},
+ {ID: "nostate", Name: "nostate", Region: "nl", Host: "1.1.1.3", Enabled: true, SSH: SSHConfig{User: "root", Port: 22, Auth: "key"}, Protocols: []ProtocolProfile{{Type: "socks5", Enabled: true, Port: 1080}}},
+ }
+ states := map[string]*NodeState{
+ "healthy": {NodeID: "healthy", BootstrapStatus: "healthy"},
+ "failed": {NodeID: "failed", BootstrapStatus: "failed"},
+ }
+
+ got := PublishableNodes(nodes, states)
+ if len(got) != 1 {
+ t.Fatalf("len(PublishableNodes) = %d, want 1", len(got))
+ }
+ if got[0].ID != "healthy" {
+ t.Fatalf("expected healthy node, got %s", got[0].ID)
+ }
+}
+
+func TestPublishableNodesRequiresRunningServicesWhenKnown(t *testing.T) {
+ t.Parallel()
+
+ nodes := []Node{
+ {ID: "healthy", Name: "healthy", Region: "nl", Host: "1.1.1.1", Enabled: true, SSH: SSHConfig{User: "root", Port: 22, Auth: "key"}, Protocols: []ProtocolProfile{{Type: "socks5", Enabled: true, Port: 1080}}},
+ {ID: "degraded", Name: "degraded", Region: "nl", Host: "1.1.1.2", Enabled: true, SSH: SSHConfig{User: "root", Port: 22, Auth: "key"}, Protocols: []ProtocolProfile{{Type: "socks5", Enabled: true, Port: 1080}}},
+ }
+ states := map[string]*NodeState{
+ "healthy": {
+ NodeID: "healthy",
+ BootstrapStatus: "healthy",
+ Services: []ServiceStatus{{Type: "socks5", Status: "running", Port: 1080}},
+ Metadata: map[string]any{"healthz_http_code": 200},
+ },
+ "degraded": {
+ NodeID: "degraded",
+ BootstrapStatus: "healthy",
+ Services: []ServiceStatus{{Type: "socks5", Status: "unknown", Port: 1080}},
+ Metadata: map[string]any{"healthz_http_code": 503},
+ },
+ }
+
+ got := PublishableNodes(nodes, states)
+ if len(got) != 1 {
+ t.Fatalf("len(PublishableNodes) = %d, want 1", len(got))
+ }
+ if got[0].ID != "healthy" {
+ t.Fatalf("expected healthy node, got %s", got[0].ID)
+ }
+}
+
+func TestPublishDecisionForNode(t *testing.T) {
+ t.Parallel()
+
+ node := Node{
+ ID: "nl-01",
+ Name: "NL 01",
+ Region: "nl",
+ Host: "203.0.113.10",
+ Domain: "nl-01.example.com",
+ Enabled: true,
+ SSH: SSHConfig{User: "root", Port: 22, Auth: "key"},
+ Protocols: []ProtocolProfile{
+ {Type: "vless", Enabled: true, Port: 443},
+ },
+ }
+
+ blocked := PublishDecisionForNode(node, &NodeState{
+ NodeID: "nl-01",
+ BootstrapStatus: "healthy",
+ Services: []ServiceStatus{{Type: "vless", Status: "configured", Port: 443}},
+ Metadata: map[string]any{"healthz_http_code": 503},
+ })
+ if blocked.Eligible {
+ t.Fatal("expected blocked publish decision")
+ }
+ if len(blocked.Reasons) == 0 {
+ t.Fatal("expected reasons for blocked decision")
+ }
+
+ ready := PublishDecisionForNode(node, &NodeState{
+ NodeID: "nl-01",
+ BootstrapStatus: "healthy",
+ PublicHost: "nl-01.example.com",
+ Services: []ServiceStatus{{Type: "vless", Status: "running", Port: 443}},
+ Metadata: map[string]any{"healthz_http_code": 200},
+ })
+ if !ready.Eligible {
+ t.Fatalf("expected ready decision, got reasons: %v", ready.Reasons)
+ }
+ if ready.PublicHost != "nl-01.example.com" {
+ t.Fatalf("unexpected public host %q", ready.PublicHost)
+ }
+}
+
+func TestBuildCatalogV2(t *testing.T) {
+ t.Parallel()
+
+ nodes := []Node{
+ {
+ ID: "nl-01",
+ Name: "NL 01",
+ Provider: "custom-vps",
+ Region: "nl",
+ Host: "203.0.113.10",
+ Domain: "nl-01.example.com",
+ Enabled: true,
+ SSH: SSHConfig{User: "root", Port: 22, Auth: "key"},
+ Protocols: []ProtocolProfile{
+ {Type: "vless", Enabled: true, Port: 443, TLS: &TLSProfile{Enabled: true, ServerName: "nl-01.example.com"}, Auth: &AuthProfile{UUID: "11111111-1111-1111-1111-111111111111"}},
+ {Type: "hysteria2", Enabled: true, Port: 9443, Auth: &AuthProfile{Password: "hidden"}, Extra: map[string]any{"obfs_password": "masked"}},
+ },
+ },
+ }
+ states := map[string]*NodeState{
+ "nl-01": {NodeID: "nl-01", BootstrapStatus: "healthy", PublicHost: "nl-01.example.com", Metadata: map[string]any{"healthz_http_code": 200}},
+ }
+
+ catalog := BuildCatalogV2(nodes, states)
+ if catalog.Version != "2" {
+ t.Fatalf("catalog.Version = %q, want 2", catalog.Version)
+ }
+ if len(catalog.Nodes) != 1 {
+ t.Fatalf("len(catalog.Nodes) = %d, want 1", len(catalog.Nodes))
+ }
+ if catalog.Nodes[0].PublicHost != "nl-01.example.com" {
+ t.Fatalf("unexpected public host %q", catalog.Nodes[0].PublicHost)
+ }
+ if len(catalog.Nodes[0].Protocols) != 2 {
+ t.Fatalf("expected 2 protocols, got %d", len(catalog.Nodes[0].Protocols))
+ }
+ if catalog.Nodes[0].Protocols[0].Type != "vless" {
+ t.Fatalf("unexpected first protocol %q", catalog.Nodes[0].Protocols[0].Type)
+ }
+}
+
+func TestMergeLegacyServersPreservesStaticEntries(t *testing.T) {
+ t.Parallel()
+
+ static := []models.Server{
+ {Tag: "nl-1", Type: "socks", Server: "1.1.1.1", ServerPort: 1080},
+ {Tag: "nl-ss-1", Type: "shadowsocks", Server: "ss.example.com", ServerPort: 443},
+ }
+ dynamic := []models.Server{
+ {Tag: "node-1-vless", Type: "vless", Server: "2.2.2.2", ServerPort: 443},
+ }
+
+ merged := MergeLegacyServers(static, dynamic)
+ if len(merged) != 3 {
+ t.Fatalf("len(merged) = %d, want 3", len(merged))
+ }
+}
+
+func TestWriteLegacyCatalogMergesStaticServers(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ staticPath := filepath.Join(dir, "static-servers.json")
+ if err := os.WriteFile(staticPath, []byte(`{"servers":[{"tag":"nl-1","region":"NL","type":"socks","server":"1.1.1.1","server_port":1080}]}`), 0o644); err != nil {
+ t.Fatalf("write static servers: %v", err)
+ }
+
+ err := WriteLegacyCatalog(filepath.Join(dir, "servers.json"), []Node{
+ {
+ ID: "node-1",
+ Name: "Node 1",
+ Region: "nl",
+ Host: "2.2.2.2",
+ Enabled: true,
+ SSH: SSHConfig{User: "root", Port: 22, Auth: "key"},
+ Protocols: []ProtocolProfile{
+ {Type: "socks5", Enabled: true, Port: 1081},
+ },
+ },
+ })
+ if err != nil {
+ t.Fatalf("WriteLegacyCatalog error = %v", err)
+ }
+
+ data, err := os.ReadFile(filepath.Join(dir, "servers.json"))
+ if err != nil {
+ t.Fatalf("read merged servers: %v", err)
+ }
+ text := string(data)
+ if !strings.Contains(text, `"tag": "nl-1"`) {
+ t.Fatalf("expected static server in merged catalog: %s", text)
+ }
+ if !strings.Contains(text, `"tag": "node-1-socks5"`) {
+ t.Fatalf("expected dynamic server in merged catalog: %s", text)
+ }
+}
+
+func TestWriteCatalogV2DoesNotMergeStaticLegacyServers(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ staticPath := filepath.Join(dir, "static-servers.json")
+ if err := os.WriteFile(staticPath, []byte(`{"servers":[{"tag":"nl-ss-1","region":"NL","type":"shadowsocks","server":"ss.example.com","server_port":443,"method":"chacha20-ietf-poly1305","password":"secret"}]}`), 0o644); err != nil {
+ t.Fatalf("write static servers: %v", err)
+ }
+
+ err := WriteCatalogV2(filepath.Join(dir, "catalog-v2.json"), []Node{
+ {
+ ID: "node-1",
+ Name: "Node 1",
+ Region: "nl",
+ Host: "2.2.2.2",
+ Enabled: true,
+ SSH: SSHConfig{User: "root", Port: 22, Auth: "key"},
+ Protocols: []ProtocolProfile{
+ {Type: "vless", Enabled: true, Port: 443, Auth: &AuthProfile{UUID: "11111111-1111-1111-1111-111111111111"}},
+ },
+ },
+ }, map[string]*NodeState{})
+ if err != nil {
+ t.Fatalf("WriteCatalogV2 error = %v", err)
+ }
+
+ data, err := os.ReadFile(filepath.Join(dir, "catalog-v2.json"))
+ if err != nil {
+ t.Fatalf("read catalog v2: %v", err)
+ }
+ text := string(data)
+ if strings.Contains(text, `"id": "nl-ss-1"`) {
+ t.Fatalf("did not expect static legacy node in catalog v2: %s", text)
+ }
+ if !strings.Contains(text, `"id": "node-1"`) {
+ t.Fatalf("expected dynamic node in catalog v2: %s", text)
+ }
+}