package httpapi import ( "context" "database/sql" "encoding/json" "net/http/httptest" "strings" "testing" "time" "github.com/alicebob/miniredis/v2" "github.com/go-chi/chi/v5" "github.com/redis/go-redis/v9" "github.com/wangjia/pangolin/server/internal/codes" "github.com/wangjia/pangolin/server/internal/config" "github.com/wangjia/pangolin/server/internal/nodes" agentv1 "github.com/wangjia/pangolin/server/internal/pb/agentv1" "github.com/wangjia/pangolin/server/internal/routing" "github.com/wangjia/pangolin/server/internal/store" ) // fakeConnectStore implements just the NodeStore methods ConnectNode's happy // path touches when the entitlement disables the optional gates (AdGate=false, // MaxDevices=0, DailyMB invalid) — everything else panics via the embedded nil // interface (未用到即安全,同 nodes_disconnect_test.go 的写法)。 type fakeConnectStore struct { nodes.NodeStore node *nodes.NodeRow ent *nodes.Entitlement devDp string } func (f *fakeConnectStore) EntitlementForUser(context.Context, int64) (*nodes.Entitlement, error) { return f.ent, nil } func (f *fakeConnectStore) NodeByUUID(context.Context, string) (*nodes.NodeRow, error) { return f.node, nil } func (f *fakeConnectStore) EnsureDeviceDpUUID(context.Context, int64, string) (string, int64, error) { return f.devDp, 1, nil } func (f *fakeConnectStore) PersistCredential(context.Context, int64, *agentv1.Credential, time.Time) error { return nil } // openRoutingDB opens an in-memory SQLite DB migrated up, for a real // routing.Store in tests (same pattern as internal/routing/store_sqlite_test.go). func openRoutingDB(t *testing.T) *sql.DB { t.Helper() db, err := store.Open(&config.Config{Driver: "sqlite", DSN: ":memory:"}) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = db.Close() }) if err := store.MigrateUp(db, "sqlite"); err != nil { t.Fatal(err) } return db } // seedConnectUser inserts a minimal users row so routing_profiles inserts // (which now declare FOREIGN KEY (user_id) REFERENCES users(id)) satisfy the // constraint — this DB opens with _pragma=foreign_keys(1) (internal/db/db.go), // so SQLite does enforce it, unlike a bare default SQLite connection. func seedConnectUser(t *testing.T, db *sql.DB, id int64) { t.Helper() uuid := "u-connect" if _, err := db.Exec(`INSERT INTO users (id,uuid,email,pw_hash,dp_uuid,status,created_at) VALUES (?,?,?, 'x','dp-'||?, 'active', ?)`, id, uuid, uuid+"@x", uuid, time.Now().UTC()); err != nil { t.Fatal(err) } } // newOnlineHub returns a real *nodes.Hub (backed by miniredis) with nodeUUID // registered online, so ConnectNode's a.hub.IsOnline(...) gate passes and // Push(...) succeeds without a real Redis deployment. func newOnlineHub(t *testing.T, nodeUUID string) *nodes.Hub { t.Helper() mr := miniredis.RunT(t) rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) t.Cleanup(func() { rdb.Close() }) hub := nodes.NewHub(rdb) _, done := hub.Register(nodeUUID) t.Cleanup(done) return hub } func doConnect(t *testing.T, api *NodeAPI, uid int64) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest("POST", "/v1/nodes/node-1/connect", strings.NewReader(`{"device_id":"dev-1"}`)) rctx := chi.NewRouteContext() rctx.URLParams.Add("id", "node-1") ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) ctx = context.WithValue(ctx, codes.CtxKeyUserID, uid) rec := httptest.NewRecorder() api.ConnectNode(rec, req.WithContext(ctx)) return rec } // TestConnectNode_ReadsRoutingProfile is Task 5's Step 1: a profile with a // user rule is stored for the connecting user; connect must read it (via // routingStore.Get) and forward it into BuildClientConfig — the returned // sing-box config's route.rules must contain that user rule. func TestConnectNode_ReadsRoutingProfile(t *testing.T) { const uid = int64(42) db := openRoutingDB(t) seedConnectUser(t, db, uid) rst := routing.NewStore(db) p := routing.Default() p.Rules = []routing.Rule{ {Type: "domain_suffix", Value: "github.com", Action: "proxy", Enabled: true}, } if err := rst.Upsert(context.Background(), uid, p); err != nil { t.Fatal(err) } hub := newOnlineHub(t, "node-1") nodeStore := &fakeConnectStore{ node: &nodes.NodeRow{ID: 1, UUID: "node-1", Status: "up", Endpoint: "1.2.3.4:443", RealityPBK: "pbk", RealityShortID: "sid", RealitySNI: "www.apple.com"}, ent: &nodes.Entitlement{DpUUID: "acct-dp"}, // AdGate=false, MaxDevices=0, DailyMB invalid → 跳过额外配额查询 devDp: "device-dp", } api := NewNodeAPI(nodeStore, hub, nil, "k", "http://x", nil, rst) rec := doConnect(t, api, uid) if rec.Code != 200 { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } var cfg map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &cfg); err != nil { t.Fatalf("unmarshal config: %v (body=%s)", err, rec.Body.String()) } rules, _ := cfg["route"].(map[string]any)["rules"].([]any) if ruleIndexByDomain(rules, "github.com") < 0 { t.Fatalf("connect config missing user routing rule for github.com; rules=%v", rules) } } // TestConnectNode_NilRoutingStore_FailsSafe: routingStore not injected (nil, // e.g. an unmigrated call site) must not error/panic — connect renders the // old default (prof=nil), falling back to the ?split_cn query. func TestConnectNode_NilRoutingStore_FailsSafe(t *testing.T) { const uid = int64(43) hub := newOnlineHub(t, "node-1") nodeStore := &fakeConnectStore{ node: &nodes.NodeRow{ID: 1, UUID: "node-1", Status: "up", Endpoint: "1.2.3.4:443", RealityPBK: "pbk", RealityShortID: "sid", RealitySNI: "www.apple.com"}, ent: &nodes.Entitlement{DpUUID: "acct-dp"}, devDp: "device-dp", } api := NewNodeAPI(nodeStore, hub, nil, "k", "http://x", nil, nil) // routingStore=nil rec := doConnect(t, api, uid) if rec.Code != 200 { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } var cfg map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &cfg); err != nil { t.Fatalf("unmarshal config: %v (body=%s)", err, rec.Body.String()) } // 无档案、无 ?split_cn query → 与既有行为一致:不分流,route 里无 rule_set。 if _, ok := cfg["route"].(map[string]any)["rule_set"]; ok { t.Errorf("no profile + no split_cn query should have no rule_set, got %v", cfg["route"]) } } // TestConnectNode_RoutingStoreErr_FailsSafe: routingStore.Get erroring (e.g. // corrupt profile_json) must not fail the connect — falls back to prof=nil, // same as no profile at all, and still honors the ?split_cn query fallback. func TestConnectNode_RoutingStoreErr_FailsSafe(t *testing.T) { const uid = int64(44) db := openRoutingDB(t) seedConnectUser(t, db, uid) // 写入一条无法反序列化的 profile_json,模拟 Get 出错。 if _, err := db.Exec(`INSERT INTO routing_profiles (user_id, profile_json, updated_at) VALUES (?,?,?)`, uid, "{not-json", time.Now().UTC()); err != nil { t.Fatal(err) } rst := routing.NewStore(db) hub := newOnlineHub(t, "node-1") nodeStore := &fakeConnectStore{ node: &nodes.NodeRow{ID: 1, UUID: "node-1", Status: "up", Endpoint: "1.2.3.4:443", RealityPBK: "pbk", RealityShortID: "sid", RealitySNI: "www.apple.com"}, ent: &nodes.Entitlement{DpUUID: "acct-dp"}, devDp: "device-dp", } api := NewNodeAPI(nodeStore, hub, nil, "k", "http://x", nil, rst) req := httptest.NewRequest("POST", "/v1/nodes/node-1/connect?split_cn=1", strings.NewReader(`{"device_id":"dev-1"}`)) rctx := chi.NewRouteContext() rctx.URLParams.Add("id", "node-1") ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) ctx = context.WithValue(ctx, codes.CtxKeyUserID, uid) rec := httptest.NewRecorder() api.ConnectNode(rec, req.WithContext(ctx)) if rec.Code != 200 { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } var cfg map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &cfg); err != nil { t.Fatalf("unmarshal config: %v (body=%s)", err, rec.Body.String()) } // prof=nil(读档案出错回退)+ ?split_cn=1 query 兜底 → 仍走国内分流(旧行为)。 if _, ok := cfg["route"].(map[string]any)["rule_set"]; !ok { t.Errorf("routing store error should fall back to ?split_cn query behavior (rule_set expected), got %v", cfg["route"]) } }