221 lines
8.1 KiB
Go
221 lines
8.1 KiB
Go
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"])
|
|
}
|
|
}
|