Files

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"])
}
}