diff options
Diffstat (limited to 'internal/api/handlers_test.go')
| -rw-r--r-- | internal/api/handlers_test.go | 592 |
1 files changed, 592 insertions, 0 deletions
diff --git a/internal/api/handlers_test.go b/internal/api/handlers_test.go new file mode 100644 index 0000000..262ea07 --- /dev/null +++ b/internal/api/handlers_test.go @@ -0,0 +1,592 @@ +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) + } +} |
