package app import ( "bytes" "context" "encoding/json" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync/atomic" "testing" "gopkg.in/yaml.v3" ) func newTestApp(t *testing.T) *App { t.Helper() a, err := New(Options{DataDir: t.TempDir(), CoreBinary: "/bin/true"}) if err != nil { t.Fatal(err) } mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/version" { jsonResponse(w, 200, map[string]string{"version": "test-core"}) return } w.WriteHeader(204) })) a.core.URL = mock.URL t.Cleanup(func() { mock.Close(); a.Close() }) return a } func request(t *testing.T, a *App, method, path string, body any, cookie *http.Cookie, csrf string) *httptest.ResponseRecorder { t.Helper() var b []byte if body != nil { b, _ = json.Marshal(body) } r := httptest.NewRequest(method, path, bytes.NewReader(b)) r.Header.Set("Content-Type", "application/json") if cookie != nil { r.AddCookie(cookie) } if csrf != "" { r.Header.Set("X-CSRF-Token", csrf) } w := httptest.NewRecorder() a.Handler().ServeHTTP(w, r) return w } func loginTest(t *testing.T, a *App) (*http.Cookie, string) { t.Helper() w := request(t, a, "POST", "/api/v1/auth/setup", credentials{Token: a.setupToken, Password: "test-password-long"}, nil, "") if w.Code != 200 { t.Fatalf("setup %d: %s", w.Code, w.Body) } var response struct { CSRF string `json:"csrf"` } json.Unmarshal(w.Body.Bytes(), &response) return w.Result().Cookies()[0], response.CSRF } func TestAuthCSRFAndSessionRevocation(t *testing.T) { a := newTestApp(t) if w := request(t, a, "GET", "/api/v1/settings", nil, nil, ""); w.Code != 401 { t.Fatal(w.Code) } if w := request(t, a, "POST", "/api/v1/auth/setup", credentials{Token: "wrong", Password: "test-password-long"}, nil, ""); w.Code != 403 { t.Fatal(w.Code) } cookie, csrf := loginTest(t, a) if !cookie.HttpOnly || cookie.SameSite != http.SameSiteStrictMode { t.Fatal("session cookie flags missing") } if w := request(t, a, "POST", "/api/v1/subscriptions", map[string]any{}, cookie, ""); w.Code != 403 { t.Fatal("missing CSRF allowed") } r := httptest.NewRequest("POST", "/api/v1/auth/login", strings.NewReader(`{"password":"test-password-long"}`)) r.Header.Set("Origin", "https://attacker.example") w := httptest.NewRecorder() a.Handler().ServeHTTP(w, r) if w.Code != 403 { t.Fatal("cross-site login allowed") } w = request(t, a, "PUT", "/api/v1/auth/password", credentials{CurrentPassword: "test-password-long", Password: "new-password-long"}, cookie, csrf) if w.Code != 200 { t.Fatal(w.Code, w.Body) } if w = request(t, a, "GET", "/api/v1/settings", nil, cookie, ""); w.Code != 401 { t.Fatal("old session still valid") } if w = request(t, a, "POST", "/api/v1/auth/setup", credentials{Token: a.setupToken, Password: "test-password-long"}, nil, ""); w.Code != 409 { t.Fatal("setup can run twice") } } func TestLoginRateLimit(t *testing.T) { a := newTestApp(t) loginTest(t, a) for i := 0; i < 5; i++ { if w := request(t, a, "POST", "/api/v1/auth/login", credentials{Password: "wrong"}, nil, ""); w.Code != 401 { t.Fatal(w.Code) } } if w := request(t, a, "POST", "/api/v1/auth/login", credentials{Password: "test-password-long"}, nil, ""); w.Code != 429 { t.Fatal("not rate limited", w.Code) } } func TestDecodeRejectsTrailingJSON(t *testing.T) { for _, payload := range []string{`{} {}`, `{"unknown":1}`, `{broken`, strings.Repeat("x", 17<<20)} { w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/", strings.NewReader(payload)) var target credentials if decode(w, r, &target) == nil { t.Fatal("invalid payload accepted") } } } func TestSubscriptionImportNamespaceAndProvider(t *testing.T) { provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Authorization") != "Bearer provider-token" { t.Error("missing provider header") } io.WriteString(w, "proxies:\n- name: shared\n type: http\n server: 127.0.0.1\n port: 8088\n") })) defer provider.Close() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "proxies:\n- name: shared\n type: http\n server: localhost\n port: 8081\nproxy-providers:\n remote:\n type: http\n url: "+provider.URL+"\n header:\n Authorization: [Bearer provider-token]\nexternal-controller: 0.0.0.0:9999\nrules: [MATCH,DIRECT]\n") })) defer server.Close() sub := Subscription{ID: randomID(), URL: server.URL} nodes, _, err := loadSubscription(context.Background(), sub, server.Client()) if err != nil { t.Fatal(err) } if len(nodes) != 2 || nodes[0]["name"] == nodes[1]["name"] { t.Fatal("nodes not namespaced", nodes) } for _, node := range nodes { if !strings.HasPrefix(node["name"].(string), sub.ID+"/") { t.Fatal("unstable namespace") } } sub.ID = randomID() other, _, err := loadSubscription(context.Background(), sub, server.Client()) if err != nil || other[0]["name"] == nodes[0]["name"] { t.Fatal("cross-subscription conflict", err) } } func TestSubscriptionRejectsInvalidSources(t *testing.T) { for _, body := range []string{"YmFzZTY0", "proxy-providers:\n local:\n type: file\n path: /etc/shadow\n", "proxies:\n- name: a\n type: http\n dialer-proxy: unknown-group\n", "proxies:\n- name: same\n type: http\n- name: same\n type: http\n"} { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, body) })) _, _, err := loadSubscription(context.Background(), Subscription{ID: randomID(), URL: server.URL}, server.Client()) server.Close() if err == nil { t.Fatalf("accepted %s", body) } } } func TestRefreshFailureKeepsNodesAndConfig(t *testing.T) { a := newTestApp(t) var fail atomic.Bool server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if fail.Load() { http.Error(w, "unavailable", 503) return } io.WriteString(w, "proxies:\n- name: node-A\n type: http\n server: localhost\n port: 8088\n") })) defer server.Close() id := randomID() a.state.Subscriptions = []Subscription{{ID: id, Name: "subscription", URL: server.URL, Enabled: true, IntervalHours: 6}} if err := a.Refresh(context.Background(), id); err != nil { t.Fatal(err) } before, _ := os.ReadFile(filepath.Join(a.core.Dir, "config.yaml")) fail.Store(true) if err := a.Refresh(context.Background(), id); err == nil { t.Fatal("expected failure") } after, _ := os.ReadFile(filepath.Join(a.core.Dir, "config.yaml")) state := a.Snapshot() if !bytes.Equal(before, after) || len(state.Subscriptions[0].Nodes) != 1 || state.Subscriptions[0].LastError == "" { t.Fatal("last good snapshot lost") } loaded, err := a.store.Load() if err != nil || len(loaded.Subscriptions[0].Nodes) != 1 { t.Fatal("snapshot not persisted", err) } } func TestCoreReloadRollbackAndValidation(t *testing.T) { var reloads atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/version" { jsonResponse(w, 200, map[string]string{"version": "test"}) return } if reloads.Add(1) == 1 { http.Error(w, "failed", 500) return } w.WriteHeader(204) })) defer server.Close() dir := t.TempDir() path := filepath.Join(dir, "config.yaml") os.WriteFile(path, []byte("old"), 0600) core := Core{Dir: dir, Binary: "/bin/true", URL: server.URL, HTTP: server.Client()} if err := core.Apply(context.Background(), []byte("new")); err == nil { t.Fatal("reload failure not surfaced") } actual, _ := os.ReadFile(path) if string(actual) != "old" || reloads.Load() != 2 { t.Fatal("rollback failed", string(actual), reloads.Load()) } core.Binary = "/bin/false" if err := core.Apply(context.Background(), []byte("invalid")); err == nil { t.Fatal("validation failure not surfaced") } actual, _ = os.ReadFile(path) if string(actual) != "old" { t.Fatal("bad configuration overwrote old") } } func TestStateNotCommittedWhenCoreFails(t *testing.T) { a := newTestApp(t) a.core.Binary = "/bin/false" before := a.Snapshot() err := a.change(context.Background(), func(s *State) error { s.Settings.ProxyEnabled = false; return nil }) if err == nil || a.Snapshot().Settings.ProxyEnabled != before.Settings.ProxyEnabled { t.Fatal("failed change committed") } } func TestControllerAuthFailureDoesNotStageConfig(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.yaml") os.WriteFile(path, []byte("previous"), 0600) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "unauthorized", 401) })) defer server.Close() core := Core{Dir: dir, Binary: "/bin/true", URL: server.URL, HTTP: server.Client()} if core.Apply(context.Background(), []byte("candidate")) == nil { t.Fatal("controller auth failure was treated as stopped") } data, _ := os.ReadFile(path) if string(data) != "previous" { t.Fatal("controller auth error replaced config") } } func TestCompileManualRemovalDoesNotChooseOtherNode(t *testing.T) { s := defaultState() s.Settings.SelectedNode = "removed" s.Subscriptions = []Subscription{{Enabled: true, Nodes: []map[string]any{{"name": "other", "type": "http", "server": "localhost", "port": 8088}}}} data, err := compileConfig(s, "secret") if err != nil { t.Fatal(err) } var parsed map[string]any if err := yaml.Unmarshal(data, &parsed); err != nil { t.Fatal(err) } group := parsed["proxy-groups"].([]any)[0].(map[string]any) first := group["proxies"].([]any)[0] if first != "REJECT" { t.Fatal("manual node silently changed", first) } if parsed["external-controller"] != "127.0.0.1:9090" || parsed["profile"].(map[string]any)["store-selected"] != false { t.Fatal("controller or selection persistence invalid") } s.Settings.SelectionMode = "auto" data, _ = compileConfig(s, "secret") yaml.Unmarshal(data, &parsed) if parsed["proxy-groups"].([]any)[0].(map[string]any)["proxies"].([]any)[0] != AutoGroup { t.Fatal("auto mode not selected") } } func TestSettingsValidation(t *testing.T) { valid := defaultState().Settings if err := validateSettings(valid); err != nil { t.Fatal(err) } invalid := []Settings{} s := valid s.Gateway = "192.168.1.1" s.LANIP = s.Gateway invalid = append(invalid, s) s = valid s.TunEnabled = true invalid = append(invalid, s) s = valid s.MixedPort = 9090 invalid = append(invalid, s) s = valid s.Interface = "eth0; touch /tmp/x" invalid = append(invalid, s) s = valid s.LANCIDR = "0.0.0.0/0" invalid = append(invalid, s) for _, s := range invalid { if validateSettings(s) == nil { t.Fatal("invalid settings accepted", s) } } } func TestStoreRestartRecovery(t *testing.T) { dir := t.TempDir() store, err := OpenStore(dir) if err != nil { t.Fatal(err) } s := defaultState() s.Settings.SelectedNode = "persisted-node" s.Subscriptions = []Subscription{{ID: randomID(), Name: "saved", URL: "https://example.com/sub", IntervalHours: 6, Enabled: true, Nodes: []map[string]any{{"name": "persisted-node", "type": "http"}}}} if err := store.Save(s); err != nil { t.Fatal(err) } store.Close() store, err = OpenStore(dir) if err != nil { t.Fatal(err) } defer store.Close() loaded, err := store.Load() if err != nil || loaded.Settings.SelectedNode != "persisted-node" || len(loaded.Subscriptions) != 1 { t.Fatal("restart lost state", err) } info, _ := os.Stat(filepath.Join(dir, "clashweb.sqlite")) if info.Mode().Perm() != 0600 { t.Fatal("database is not private") } } func TestAPINodeListDoesNotExposeCredentials(t *testing.T) { a := newTestApp(t) cookie, _ := loginTest(t, a) a.state.Subscriptions = []Subscription{{ID: randomID(), Name: "private", Enabled: true, Nodes: []map[string]any{{"name": "id/inline/node", "type": "ss", "password": "do-not-expose", "server": "private.example"}}}} w := request(t, a, "GET", "/api/v1/nodes", nil, cookie, "") if w.Code != 200 || strings.Contains(w.Body.String(), "do-not-expose") || strings.Contains(w.Body.String(), "private.example") { t.Fatal("credentials leaked", w.Body) } }