210 lines
7.1 KiB
Go
210 lines
7.1 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/wangjia/pangolin/server/internal/codes"
|
|
"github.com/wangjia/pangolin/server/internal/config"
|
|
"github.com/wangjia/pangolin/server/internal/routing"
|
|
"github.com/wangjia/pangolin/server/internal/store"
|
|
)
|
|
|
|
// openRoutingTestDB opens an in-memory SQLite DB with migrations applied,
|
|
// mirroring internal/routing/store_sqlite_test.go's helper (no shared helper
|
|
// exists in this package yet).
|
|
func openRoutingTestDB(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)
|
|
}
|
|
if err := store.ApplyCodesLibMigrations(context.Background(), db, "sqlite"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return db
|
|
}
|
|
|
|
func seedRoutingUser(t *testing.T, db *sql.DB, id int64) {
|
|
t.Helper()
|
|
uuid := "u-routing"
|
|
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)
|
|
}
|
|
}
|
|
|
|
// doAuthReq builds an httptest request with an authenticated context (numeric
|
|
// user id injected under codes.CtxKeyUserID, matching auth.RequireAuth) and
|
|
// invokes the handler directly (no router needed for a single route).
|
|
func doAuthReq(t *testing.T, method, target string, body *strings.Reader, uid int64, h http.HandlerFunc) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
var req *http.Request
|
|
if body == nil {
|
|
req = httptest.NewRequest(method, target, nil)
|
|
} else {
|
|
req = httptest.NewRequest(method, target, body)
|
|
}
|
|
ctx := context.WithValue(req.Context(), codes.CtxKeyUserID, uid)
|
|
req = req.WithContext(ctx)
|
|
rr := httptest.NewRecorder()
|
|
h(rr, req)
|
|
return rr
|
|
}
|
|
|
|
func TestRoutingGetDefaultThenSave(t *testing.T) {
|
|
db := openRoutingTestDB(t)
|
|
seedRoutingUser(t, db, 7)
|
|
api := NewRoutingAPI(routing.NewStore(db), nil)
|
|
|
|
// GET 无档案 → 200 + Default
|
|
rr := doAuthReq(t, http.MethodGet, "/v1/me/routing", nil, 7, api.GetProfile)
|
|
if rr.Code != 200 {
|
|
t.Fatalf("GET code %d", rr.Code)
|
|
}
|
|
var p routing.Profile
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &p); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if p.Mode != "rule" {
|
|
t.Fatalf("default mode %s", p.Mode)
|
|
}
|
|
|
|
// POST 合法 → 200
|
|
body := `{"mode":"rule","builtin":{"china_direct":true,"lan_direct":true,"private_via_tunnel":true},"rules":[{"type":"domain_suffix","value":"x.com","action":"direct","enabled":true}],"final":"proxy"}`
|
|
rr = doAuthReq(t, http.MethodPost, "/v1/me/routing", strings.NewReader(body), 7, api.SaveProfile)
|
|
if rr.Code != 200 {
|
|
t.Fatalf("POST code %d body %s", rr.Code, rr.Body)
|
|
}
|
|
|
|
// GET 后应能取回刚保存的档案
|
|
rr = doAuthReq(t, http.MethodGet, "/v1/me/routing", nil, 7, api.GetProfile)
|
|
if rr.Code != 200 {
|
|
t.Fatalf("GET-after-save code %d", rr.Code)
|
|
}
|
|
var p2 routing.Profile
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &p2); err != nil {
|
|
t.Fatalf("unmarshal2: %v", err)
|
|
}
|
|
if len(p2.Rules) != 1 || p2.Rules[0].Value != "x.com" {
|
|
t.Fatalf("saved profile not persisted: %+v", p2)
|
|
}
|
|
|
|
// POST 非法 → 400 + errors
|
|
rr = doAuthReq(t, http.MethodPost, "/v1/me/routing", strings.NewReader(`{"mode":"x","final":"y","rules":[]}`), 7, api.SaveProfile)
|
|
if rr.Code != 400 {
|
|
t.Fatalf("bad POST code %d", rr.Code)
|
|
}
|
|
var errBody map[string]any
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &errBody); err != nil {
|
|
t.Fatalf("unmarshal err body: %v", err)
|
|
}
|
|
if errBody["code"] != "routing_invalid" {
|
|
t.Fatalf("bad POST body code = %v", errBody["code"])
|
|
}
|
|
errs, ok := errBody["errors"].([]any)
|
|
if !ok || len(errs) == 0 {
|
|
t.Fatalf("expected non-empty errors, got %v", errBody["errors"])
|
|
}
|
|
}
|
|
|
|
func TestRoutingGetUnauthorized(t *testing.T) {
|
|
db := openRoutingTestDB(t)
|
|
api := NewRoutingAPI(routing.NewStore(db), nil)
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/me/routing", nil)
|
|
rr := httptest.NewRecorder()
|
|
api.GetProfile(rr, req)
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401, got %d", rr.Code)
|
|
}
|
|
}
|
|
|
|
// TestRoutingGetExposesSystemLockedDomains: GET must surface the injected
|
|
// lockedDomains list under system_locked_domains, without it ever ending up
|
|
// as part of routing.Profile's own field set.
|
|
func TestRoutingGetExposesSystemLockedDomains(t *testing.T) {
|
|
db := openRoutingTestDB(t)
|
|
seedRoutingUser(t, db, 8)
|
|
api := NewRoutingAPI(routing.NewStore(db), []string{"nas.x.com"})
|
|
|
|
rr := doAuthReq(t, http.MethodGet, "/v1/me/routing", nil, 8, api.GetProfile)
|
|
if rr.Code != 200 {
|
|
t.Fatalf("GET code %d", rr.Code)
|
|
}
|
|
var resp struct {
|
|
SystemLockedDomains []string `json:"system_locked_domains"`
|
|
}
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if len(resp.SystemLockedDomains) != 1 || resp.SystemLockedDomains[0] != "nas.x.com" {
|
|
t.Fatalf("want system_locked_domains=[nas.x.com], got %v", resp.SystemLockedDomains)
|
|
}
|
|
}
|
|
|
|
// TestRoutingGetSystemLockedDomainsNilBecomesEmptyArray: a nil lockedDomains
|
|
// slice (no PANGOLIN_PRIVATE_SPLIT_DOMAINS configured) must serialize as
|
|
// `[]`, not `null` — clients shouldn't need a nil-check.
|
|
func TestRoutingGetSystemLockedDomainsNilBecomesEmptyArray(t *testing.T) {
|
|
db := openRoutingTestDB(t)
|
|
seedRoutingUser(t, db, 9)
|
|
api := NewRoutingAPI(routing.NewStore(db), nil)
|
|
|
|
rr := doAuthReq(t, http.MethodGet, "/v1/me/routing", nil, 9, api.GetProfile)
|
|
if rr.Code != 200 {
|
|
t.Fatalf("GET code %d", rr.Code)
|
|
}
|
|
var raw map[string]json.RawMessage
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &raw); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if got := string(raw["system_locked_domains"]); got != "[]" {
|
|
t.Fatalf("want system_locked_domains=`[]`, got %s", got)
|
|
}
|
|
}
|
|
|
|
// TestRoutingSaveIgnoresSystemLockedDomains: a client POSTing a body that
|
|
// includes system_locked_domains must not have it persisted — SaveProfile
|
|
// decodes straight into routing.Profile, which has no such field, so the
|
|
// key is silently dropped. Verify against the raw stored row (not the GET
|
|
// response, which always injects it from a.lockedDomains regardless of what
|
|
// was ever saved).
|
|
func TestRoutingSaveIgnoresSystemLockedDomains(t *testing.T) {
|
|
db := openRoutingTestDB(t)
|
|
seedRoutingUser(t, db, 10)
|
|
store := routing.NewStore(db)
|
|
api := NewRoutingAPI(store, []string{"nas.x.com"})
|
|
|
|
body := `{"mode":"rule","builtin":{"china_direct":true,"lan_direct":true,"private_via_tunnel":true},` +
|
|
`"rules":[],"final":"proxy","system_locked_domains":["evil.attacker.com"]}`
|
|
rr := doAuthReq(t, http.MethodPost, "/v1/me/routing", strings.NewReader(body), 10, api.SaveProfile)
|
|
if rr.Code != 200 {
|
|
t.Fatalf("POST code %d body %s", rr.Code, rr.Body)
|
|
}
|
|
|
|
stored, err := store.Get(context.Background(), 10)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if stored == nil {
|
|
t.Fatal("expected a persisted profile")
|
|
}
|
|
raw, err := json.Marshal(stored)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(string(raw), "system_locked_domains") || strings.Contains(string(raw), "evil.attacker.com") {
|
|
t.Fatalf("system_locked_domains must never be persisted, got stored profile JSON: %s", raw)
|
|
}
|
|
}
|