package api_test import ( "encoding/base64" "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "vpnem/internal/api" "vpnem/internal/control" "vpnem/internal/models" "vpnem/internal/rules" ) func setupTestStore(t *testing.T) *rules.Store { t.Helper() dir := t.TempDir() writeJSON(t, filepath.Join(dir, "servers.json"), models.ServersResponse{ Servers: []models.Server{ {Tag: "test-1", Region: "NL", Type: "socks", Server: "1.2.3.4", ServerPort: 1080}, { Tag: "test-vless", Region: "NL", Type: "vless", Server: "nl.example.com", ServerPort: 443, UUID: "11111111-1111-1111-1111-111111111111", TLS: &models.TLS{Enabled: true, ServerName: "nl.example.com"}, Transport: &models.Transport{Type: "ws", Path: "/ws"}, }, { Tag: "test-ss", Region: "DE", Type: "shadowsocks", Server: "de.example.com", ServerPort: 8443, Method: "2022-blake3-aes-128-gcm", Password: "secret", }, }, }) writeJSON(t, filepath.Join(dir, "rulesets.json"), models.RuleSetManifest{ RuleSets: []models.RuleSet{ {Tag: "test-rules", Description: "test", URL: "https://example.com/test.srs", Format: "binary", Type: "domain"}, }, }) writeJSON(t, filepath.Join(dir, "version.json"), models.VersionResponse{ Version: "0.1.0", Changelog: "test", }) writeJSON(t, filepath.Join(dir, "routing-policy.json"), models.RoutingPolicy{ Version: "test-policy", AlwaysDirectProcesses: []string{"chromium.exe"}, BlockedDomains: []string{"example.com"}, }) writeJSON(t, filepath.Join(dir, "catalog-v2.json"), models.CatalogV2{ Version: "2", Nodes: []models.CatalogNode{ { ID: "test-vless", Name: "Test VLESS", Region: "NL", Host: "1.2.3.4", PublicHost: "nl.example.com", Status: "healthy", Protocols: []models.CatalogProtocol{ { Type: "vless", Enabled: true, Port: 443, TLS: &models.TLS{Enabled: true, ServerName: "nl.example.com"}, Auth: &models.CatalogAuth{UUID: "11111111-1111-1111-1111-111111111111"}, Extra: map[string]any{"transport_type": "ws", "path": "/ws"}, }, { Type: "vmess", Enabled: true, Port: 8444, TLS: &models.TLS{Enabled: true, ServerName: "nl.example.com"}, Auth: &models.CatalogAuth{UUID: "22222222-2222-2222-2222-222222222222"}, Extra: map[string]any{"path": "/vmess"}, }, { Type: "hysteria2", Enabled: true, Port: 9443, TLS: &models.TLS{Enabled: true, ServerName: "nl.example.com", Insecure: true, ALPN: []string{"h3"}, MinVersion: "1.3", MaxVersion: "1.3"}, Auth: &models.CatalogAuth{Password: "hy2-secret"}, Extra: map[string]any{"obfs_password": "obfs-secret"}, }, { Type: "vless-reality", Enabled: true, Port: 443, TLS: &models.TLS{ Enabled: true, ServerName: "login.microsoftonline.com", Reality: &models.Reality{ Enabled: true, PublicKey: "jNXHt1yRo0vDuchQlIP6Z0ZvjT3KtzVI-T4E7RoLJS0", ShortID: "0123456789abcdef", Fingerprint: "chrome", }, }, Auth: &models.CatalogAuth{UUID: "33333333-3333-3333-3333-333333333333"}, }, }, }, }, }) os.MkdirAll(filepath.Join(dir, "rules"), 0o755) return rules.NewStore(dir) } func writeJSON(t *testing.T, path string, v any) { t.Helper() data, err := json.MarshalIndent(v, "", " ") if err != nil { t.Fatal(err) } if err := os.WriteFile(path, data, 0o644); err != nil { t.Fatal(err) } } func TestServersEndpoint(t *testing.T) { store := setupTestStore(t) router := api.NewRouter(store) req := httptest.NewRequest("GET", "/api/v1/servers", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } var resp models.ServersResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("invalid json: %v", err) } if len(resp.Servers) != 3 { t.Fatalf("expected 3 servers, got %d", len(resp.Servers)) } if resp.Servers[0].Tag != "test-1" { t.Errorf("expected first tag test-1, got %s", resp.Servers[0].Tag) } } func TestSubscribeEndpoint(t *testing.T) { store := setupTestStore(t) router := api.NewRouter(store) req := httptest.NewRequest("GET", "/api/v1/subscribe", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(w.Body.String())) if err != nil { t.Fatalf("expected base64 response: %v", err) } body := string(decoded) if !strings.Contains(body, "vless://11111111-1111-1111-1111-111111111111@nl.example.com:443?") { t.Fatalf("expected vless link in subscription, got %q", body) } if !strings.Contains(body, "vmess://") { t.Fatalf("expected vmess link in subscription, got %q", body) } if !strings.Contains(body, "hysteria2://hy2-secret@nl.example.com:9443/?") { t.Fatalf("expected hysteria2 link in subscription, got %q", body) } if !strings.Contains(body, "insecure=1") || !strings.Contains(body, "alpn=h3") { t.Fatalf("expected hysteria2 insecure/alpn query params in subscription, got %q", body) } if !strings.Contains(body, "security=reality") || !strings.Contains(body, "pbk=jNXHt1yRo0vDuchQlIP6Z0ZvjT3KtzVI-T4E7RoLJS0") { t.Fatalf("expected reality link in subscription, got %q", body) } if strings.Contains(body, "socks5://1.2.3.4:1080#test-1") { t.Fatalf("did not expect legacy-only socks link when catalog-v2 is available, got %q", body) } } func TestSubscribeEndpointPlain(t *testing.T) { store := setupTestStore(t) router := api.NewRouter(store) req := httptest.NewRequest("GET", "/api/v1/subscribe?format=plain", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } if !strings.Contains(w.Body.String(), "vless://") { t.Fatalf("expected plain subscription links, got %q", w.Body.String()) } if !strings.Contains(w.Body.String(), "vmess://") { t.Fatalf("expected vmess in plain subscription, got %q", w.Body.String()) } if !strings.Contains(w.Body.String(), "hysteria2://") { t.Fatalf("expected hysteria2 in plain subscription, got %q", w.Body.String()) } if !strings.Contains(w.Body.String(), "security=reality") { t.Fatalf("expected reality in plain subscription, got %q", w.Body.String()) } } func TestSubscribeEndpointPlainLegacyFallbackPreservesTags(t *testing.T) { dir := t.TempDir() writeJSON(t, filepath.Join(dir, "servers.json"), models.ServersResponse{ Servers: []models.Server{ {Tag: "legacy-socks", Region: "NL", Type: "socks", Server: "1.2.3.4", ServerPort: 1080}, {Tag: "legacy-ss", Region: "NL", Type: "shadowsocks", Server: "ss.example.com", ServerPort: 8388, Method: "chacha20-ietf-poly1305", Password: "secret"}, }, }) writeJSON(t, filepath.Join(dir, "rulesets.json"), models.RuleSetManifest{}) writeJSON(t, filepath.Join(dir, "version.json"), models.VersionResponse{Version: "0.1.0"}) writeJSON(t, filepath.Join(dir, "routing-policy.json"), models.RoutingPolicy{Version: "test"}) store := rules.NewStore(dir) router := api.NewRouter(store) req := httptest.NewRequest("GET", "/api/v1/subscribe?format=plain", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } body := w.Body.String() if !strings.Contains(body, "socks5://1.2.3.4:1080#legacy-socks") { t.Fatalf("expected legacy socks tag in subscription, got %q", body) } if !strings.Contains(body, "#legacy-ss") { t.Fatalf("expected legacy shadowsocks tag in subscription, got %q", body) } } func TestRuleSetManifestEndpoint(t *testing.T) { store := setupTestStore(t) router := api.NewRouter(store) req := httptest.NewRequest("GET", "/api/v1/ruleset/manifest", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } var resp models.RuleSetManifest if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("invalid json: %v", err) } if len(resp.RuleSets) != 1 { t.Fatalf("expected 1 ruleset, got %d", len(resp.RuleSets)) } } func TestVersionEndpoint(t *testing.T) { store := setupTestStore(t) router := api.NewRouter(store) req := httptest.NewRequest("GET", "/api/v1/version", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } var resp models.VersionResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("invalid json: %v", err) } if resp.Version != "0.1.0" { t.Errorf("expected version 0.1.0, got %s", resp.Version) } } func TestCatalogV2Endpoint(t *testing.T) { store := setupTestStore(t) router := api.NewRouter(store) req := httptest.NewRequest("GET", "/api/v2/catalog", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } var resp models.CatalogV2 if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("invalid json: %v", err) } if resp.Version != "2" { t.Fatalf("expected version 2, got %q", resp.Version) } if len(resp.Nodes) != 1 { t.Fatalf("expected 1 node, got %d", len(resp.Nodes)) } } func TestCatalogV2EndpointFallsBackToLegacyServers(t *testing.T) { dir := t.TempDir() writeJSON(t, filepath.Join(dir, "servers.json"), models.ServersResponse{ Servers: []models.Server{ {Tag: "legacy", Region: "NL", Type: "socks", Server: "1.2.3.4", ServerPort: 1080}, }, }) writeJSON(t, filepath.Join(dir, "rulesets.json"), models.RuleSetManifest{}) writeJSON(t, filepath.Join(dir, "version.json"), models.VersionResponse{Version: "0.1.0"}) writeJSON(t, filepath.Join(dir, "routing-policy.json"), models.RoutingPolicy{Version: "test"}) store := rules.NewStore(dir) router := api.NewRouter(store) req := httptest.NewRequest("GET", "/api/v2/catalog", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp models.CatalogV2 if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("invalid json: %v", err) } if resp.Version != "legacy-adapter" { t.Fatalf("expected legacy-adapter version, got %q", resp.Version) } if len(resp.Nodes) != 1 || resp.Nodes[0].ID != "legacy" { t.Fatalf("unexpected fallback catalog payload: %+v", resp) } } func TestCatalogV2EndpointMissingReturns404(t *testing.T) { dir := t.TempDir() writeJSON(t, filepath.Join(dir, "rulesets.json"), models.RuleSetManifest{}) writeJSON(t, filepath.Join(dir, "version.json"), models.VersionResponse{Version: "0.1.0"}) writeJSON(t, filepath.Join(dir, "routing-policy.json"), models.RoutingPolicy{Version: "test"}) store := rules.NewStore(dir) router := api.NewRouter(store) req := httptest.NewRequest("GET", "/api/v2/catalog", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusNotFound { t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String()) } } func TestRoutingPolicyEndpoint(t *testing.T) { store := setupTestStore(t) router := api.NewRouter(store) req := httptest.NewRequest("GET", "/api/v1/routing-policy", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } var resp models.RoutingPolicy if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("invalid json: %v", err) } if resp.Version != "test-policy" { t.Fatalf("expected version test-policy, got %q", resp.Version) } if len(resp.AlwaysDirectProcesses) != 1 || resp.AlwaysDirectProcesses[0] != "chromium.exe" { t.Fatalf("unexpected routing policy payload: %+v", resp) } } func TestMethodNotAllowed(t *testing.T) { store := setupTestStore(t) router := api.NewRouter(store) req := httptest.NewRequest("POST", "/api/v1/servers", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code == http.StatusOK { t.Fatal("POST should not return 200") } } func TestVPNUIEndpoint(t *testing.T) { store := setupTestStore(t) router := api.NewRouter(store) req := httptest.NewRequest("GET", "/vpnui", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusTemporaryRedirect { t.Fatalf("expected 307, got %d", w.Code) } if got := w.Header().Get("Location"); got != "/vpnui/" { t.Fatalf("expected redirect to /vpnui/, got %q", got) } req = httptest.NewRequest("GET", "/vpnui/", nil) w = httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200 for /vpnui/, got %d", w.Code) } if !strings.Contains(w.Body.String(), "Панель управления vpnem") { t.Fatal("expected control ui html") } } func TestControlNodeUpsertAndList(t *testing.T) { store := setupTestStore(t) router := api.NewRouter(store) body := `{ "id":"nl-01", "name":"NL 01", "provider":"custom-vps", "region":"nl", "host":"203.0.113.10", "domain":"nl-01.example.com", "enabled":true, "ssh":{"user":"root","port":22,"auth":"key","identity_file":"~/.ssh/id_ed25519"}, "protocols":[ { "type":"vless", "enabled":true, "port":443, "tls":{"enabled":true,"server_name":"nl-01.example.com"}, "auth":{"uuid":"11111111-1111-1111-1111-111111111111"}, "extra":{"transport_type":"ws","path":"/ws"} } ] }` req := httptest.NewRequest("POST", "/api/v1/control/nodes", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200 on save, got %d: %s", w.Code, w.Body.String()) } listReq := httptest.NewRequest("GET", "/api/v1/control/nodes", nil) listW := httptest.NewRecorder() router.ServeHTTP(listW, listReq) if listW.Code != http.StatusOK { t.Fatalf("expected 200 on list, got %d", listW.Code) } var resp struct { Nodes []control.Node `json:"nodes"` States map[string]*control.NodeState `json:"states"` } if err := json.Unmarshal(listW.Body.Bytes(), &resp); err != nil { t.Fatalf("invalid json: %v", err) } if len(resp.Nodes) != 1 { t.Fatalf("expected 1 node, got %d", len(resp.Nodes)) } if resp.Nodes[0].ID != "nl-01" { t.Fatalf("expected nl-01, got %s", resp.Nodes[0].ID) } } func TestControlCatalogPublish(t *testing.T) { store := setupTestStore(t) if _, err := control.SaveNodeFile(filepath.Join(store.DataDir(), "control", "inventory"), control.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: control.SSHConfig{User: "root", Port: 22, Auth: "key", IdentityFile: "~/.ssh/id_ed25519"}, Protocols: []control.ProtocolProfile{ { Type: "vless", Enabled: true, Port: 443, TLS: &control.TLSProfile{Enabled: true, ServerName: "nl-01.example.com"}, Auth: &control.AuthProfile{UUID: "11111111-1111-1111-1111-111111111111"}, Extra: map[string]any{"transport_type": "ws", "path": "/ws"}, }, }, }); err != nil { t.Fatal(err) } if err := control.SaveNodeState(filepath.Join(store.DataDir(), "control", "state"), control.NodeState{ NodeID: "nl-01", BootstrapStatus: "healthy", PublicHost: "nl-01.example.com", }); err != nil { t.Fatal(err) } router := api.NewRouter(store) req := httptest.NewRequest("POST", "/api/v1/control/catalog/publish", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } data, err := os.ReadFile(filepath.Join(store.DataDir(), "servers.json")) if err != nil { t.Fatal(err) } if !strings.Contains(string(data), `"tag": "nl-01-vless"`) { t.Fatal("expected published vless server in servers.json") } catalogData, err := os.ReadFile(filepath.Join(store.DataDir(), "catalog-v2.json")) if err != nil { t.Fatal(err) } if !strings.Contains(string(catalogData), `"version": "2"`) { t.Fatal("expected catalog-v2.json to be published") } } func TestDeleteControlNode(t *testing.T) { store := setupTestStore(t) if _, err := control.SaveNodeFile(filepath.Join(store.DataDir(), "control", "inventory"), control.Node{ ID: "nl-delete", Name: "Delete Node", Provider: "custom-vps", Region: "nl", Host: "203.0.113.20", Domain: "nl-delete.example.com", Enabled: true, SSH: control.SSHConfig{User: "root", Port: 22, Auth: "key", IdentityFile: "~/.ssh/id_ed25519"}, Protocols: []control.ProtocolProfile{ { Type: "vless", Enabled: true, Port: 443, TLS: &control.TLSProfile{Enabled: true, ServerName: "nl-delete.example.com"}, Auth: &control.AuthProfile{UUID: "11111111-1111-1111-1111-111111111111"}, }, }, }); err != nil { t.Fatal(err) } if err := control.SaveNodeState(filepath.Join(store.DataDir(), "control", "state"), control.NodeState{ NodeID: "nl-delete", BootstrapStatus: "healthy", }); err != nil { t.Fatal(err) } router := api.NewRouter(store) req := httptest.NewRequest("DELETE", "/api/v1/control/nodes/nl-delete", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } if _, err := os.Stat(filepath.Join(store.DataDir(), "control", "inventory", "nl-delete.yaml")); !os.IsNotExist(err) { t.Fatalf("expected node file to be deleted, got err=%v", err) } if _, err := os.Stat(filepath.Join(store.DataDir(), "control", "state", "nl-delete.json")); !os.IsNotExist(err) { t.Fatalf("expected node state to be deleted, got err=%v", err) } }