Files
3x-ui-agent/internal/web/controller/probe_public_test.go
T
Muse 0d1607c358 B4 tests: probe store, agent prober, controller + wire
Baseline note: TestMetricHistoryMinuteFold fails identically on
the pristine B3 tip in the same wall-clock window (minute-parity
sensitive seed), passes in others — pre-existing, untouched path.
2026-10-22 22:09:11 +00:00

594 lines
20 KiB
Go

package controller
import (
"bytes"
"compress/gzip"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"testing"
"time"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
ws "github.com/gorilla/websocket"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/probe"
"github.com/mhsanaei/3x-ui/v3/internal/web/session"
)
// Item 7 B3 contract tests: the five public endpoints, audience
// discipline (anonymous callers never see addresses / hostnames /
// private remarks), theme hosting + fallback, theme config round
// trip, and the probe-meta edit endpoints. Field sets are asserted
// exactly against the hub's node_view / metrics definitions.
type probeTestRig struct {
engine *gin.Engine
dbDir string
store *probe.Store
cookies []*http.Cookie
}
func newProbeTestRig(t *testing.T) *probeTestRig {
t.Helper()
gin.SetMode(gin.TestMode)
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
st := probe.Default()
if st == nil {
t.Fatal("probe store unavailable")
}
service.InvalidateProbeSnapshot()
engine := gin.New()
store := cookie.NewStore([]byte("probe-public-test-secret"))
engine.Use(sessions.Sessions("3x-ui", store))
engine.Use(func(c *gin.Context) { c.Set("base_path", "/panel/") })
a := &APIController{}
engine.GET("/test-login", func(c *gin.Context) {
var user model.User
if err := database.GetDB().First(&user).Error; err != nil {
c.Status(http.StatusInternalServerError)
return
}
if err := session.SetLoginUser(c, &user); err != nil {
c.Status(http.StatusInternalServerError)
return
}
c.Status(http.StatusOK)
})
pc := NewPublicProbeController(engine)
agentAdmin := engine.Group("/panel/api/agent")
agentAdmin.Use(a.checkAPIAuth)
NewProbeMetaController(agentAdmin)
NewProbeTasksController(agentAdmin)
engine.NoRoute(func(c *gin.Context) {
if pc.HandleFallback(c) {
return
}
c.AbortWithStatus(http.StatusNotFound)
})
rig := &probeTestRig{engine: engine, dbDir: dbDir, store: st}
// Log in once; handlers that need the anonymous side just skip the
// cookies.
rec := rig.do(http.MethodGet, "/test-login", nil, false)
if rec.Code != http.StatusOK {
t.Fatalf("test login: %d", rec.Code)
}
rig.cookies = rec.Result().Cookies()
if len(rig.cookies) == 0 {
t.Fatal("test login set no session cookie")
}
return rig
}
func (r *probeTestRig) do(method, path string, body []byte, authed bool) *httptest.ResponseRecorder {
var rd io.Reader
if body != nil {
rd = bytes.NewReader(body)
}
req := httptest.NewRequest(method, path, rd)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if authed {
for _, ck := range r.cookies {
req.AddCookie(ck)
}
}
rec := httptest.NewRecorder()
r.engine.ServeHTTP(rec, req)
return rec
}
func (r *probeTestRig) setSetting(t *testing.T, key, value string) {
t.Helper()
db := database.GetDB()
var row model.Setting
err := db.Where("key = ?", key).First(&row).Error
if database.IsNotFound(err) {
if err := db.Create(&model.Setting{Key: key, Value: value}).Error; err != nil {
t.Fatalf("set setting %s: %v", key, err)
}
return
}
if err != nil {
t.Fatalf("read setting %s: %v", key, err)
}
row.Value = value
if err := db.Save(&row).Error; err != nil {
t.Fatalf("save setting %s: %v", key, err)
}
}
// seedAgent creates a bound node + agent pair with a live probe
// snapshot, a meta row and a traffic ledger line.
func (r *probeTestRig) seedAgent(t *testing.T, name string, public bool) (agentID int64, nodeID int) {
t.Helper()
db := database.GetDB()
node := &model.Node{Name: name, Remark: "private-remark-" + name}
if err := db.Create(node).Error; err != nil {
t.Fatalf("seed node: %v", err)
}
now := time.Now()
agent := &model.AgentNode{
TokenHash: "hash-" + name, TokenPrefix: "tp-", Name: "agent-" + name,
NodeID: &node.Id,
LastSeen: now.UnixMilli(),
AgentVersion: "A1.0.0",
IPv4: "203.0.113.9",
SeenIPv4: "198.51.100.4",
ProbeHostname: "secret-host-" + name,
ProbeOS: "Debian GNU/Linux 13", ProbeKernel: "6.12.1", ProbeArch: "amd64",
ProbeVirt: "kvm", ProbeCPUName: "Test CPU", ProbeCPUCores: 4,
ProbeIface: "eth0",
ProbeMemTotal: 4_000_000_000, ProbeMemUsed: 1_500_000_000,
ProbeSwapTotal: 1_000_000_000, ProbeSwapUsed: 5_000_000,
ProbeDiskTotal: 8_000_000_000, ProbeDiskUsed: 3_000_000_000,
ProbeNetRxTot: 111_000, ProbeNetTxTot: 222_000,
ProbeNetRx: 1000, ProbeNetTx: 2000,
ProbeTCP: 42, ProbeUDP: 7, ProbeProcs: 123,
ProbeCPU: 12.5, UptimeSecs: 3600,
ProbeAt: now.UnixMilli(),
}
if err := db.Create(agent).Error; err != nil {
t.Fatalf("seed agent: %v", err)
}
id := int64(agent.Id)
r.store.DeleteAgentData(id) // the default store is process-wide; start clean
exp := now.AddDate(0, 1, 0).Format("2006-01-02")
meta := probe.DefaultMeta(id)
meta.Public = public
meta.Price = 10
meta.ExpiresAt = &exp
meta.GroupName = "grp"
meta.PublicRemark = "shown publicly"
if err := probe.SaveMeta(meta); err != nil {
t.Fatalf("seed meta: %v", err)
}
month := probe.PeriodStart(now, 1).Format("2006-01-02")
day := now.Format("2006-01-02")
if _, err := r.store.DB().Exec(`INSERT INTO probe_traffic
(agent_id, total_rx, total_tx, month_rx, month_tx, month_start, day_rx, day_tx, day_start)
VALUES (?, 111, 222, 33, 44, ?, 5, 6, ?)`, id, month, day); err != nil {
t.Fatalf("seed traffic: %v", err)
}
return id, node.Id
}
func keySet(t *testing.T, body []byte, path ...string) map[string]bool {
t.Helper()
var v any
if err := json.Unmarshal(body, &v); err != nil {
t.Fatalf("decode: %v", err)
}
cur := v
for _, p := range path {
m, ok := cur.(map[string]any)
if !ok {
t.Fatalf("path %v: not an object at %q", path, p)
}
cur = m[p]
}
m, ok := cur.(map[string]any)
if !ok {
t.Fatalf("path %v: not an object", path)
}
out := map[string]bool{}
for k := range m {
out[k] = true
}
return out
}
func assertKeys(t *testing.T, got map[string]bool, want []string) {
t.Helper()
w := map[string]bool{}
for _, k := range want {
w[k] = true
}
var extra, missing []string
for k := range got {
if !w[k] {
extra = append(extra, k)
}
}
for k := range w {
if !got[k] {
missing = append(missing, k)
}
}
sort.Strings(extra)
sort.Strings(missing)
if len(extra) > 0 || len(missing) > 0 {
t.Fatalf("key set mismatch: extra=%v missing=%v", extra, missing)
}
}
var anonNodeKeys = []string{
"id", "name", "country", "group", "public_remark", "sort", "public",
"online", "last_seen", "last_seen_ago", "metrics",
"os", "kernel", "arch", "virt", "cpu_name", "cpu_cores",
"mem_total", "swap_total", "disk_total", "agent_version",
"price", "currency", "billing_cycle", "expires_at", "expires_in",
"traffic_limit", "traffic_mode", "traffic_reset_day",
"total_rx", "total_tx", "month_rx", "month_tx", "month_used",
"month_start", "day_rx", "day_tx",
}
var metricKeys = []string{
"uptime", "cpu", "load", "mem_total", "mem_used", "swap_total",
"swap_used", "disk_total", "disk_used", "net_rx", "net_tx",
"tcp", "udp", "procs", "total_rx", "total_tx", "month_rx", "month_tx",
}
var historyKeys = []string{
"ts", "cpu", "mem_used", "disk_used", "net_rx", "net_tx",
"net_rx_max", "net_tx_max", "cpu_max", "minutes",
"swap_used", "tcp", "udp", "procs",
}
func TestProbePublicContract(t *testing.T) {
rig := newProbeTestRig(t)
pubID, _ := rig.seedAgent(t, "PubNode", true)
privID, privNodeID := rig.seedAgent(t, "PrivNode", false)
service.InvalidateProbeSnapshot()
// --- public page off (the shipped default) ---
rec := rig.do(http.MethodGet, "/api/me", nil, false)
if rec.Code != 200 {
t.Fatalf("me anon: %d", rec.Code)
}
assertKeys(t, keySet(t, rec.Body.Bytes()),
[]string{"authed", "github", "site_name", "public_page", "history_days", "site"})
var me map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &me)
if me["authed"] != false || me["public_page"] != false || me["site_name"] != "Monitor" ||
me["history_days"] != float64(7) || me["github"] != false || me["site"] != "" {
t.Fatalf("me defaults wrong: %v", me)
}
if rec := rig.do(http.MethodGet, "/api/nodes", nil, false); rec.Code != 401 {
t.Fatalf("nodes anon with page off: %d", rec.Code)
}
if rec := rig.do(http.MethodGet, "/api/nodes/1/metrics", nil, false); rec.Code != 401 {
t.Fatalf("metrics anon with page off: %d", rec.Code)
}
// --- public page on: the anonymous audience ---
rig.setSetting(t, "public_page", "on")
service.InvalidateProbeSnapshot()
rec = rig.do(http.MethodGet, "/api/nodes", nil, false)
if rec.Code != 200 {
t.Fatalf("nodes anon: %d (%s)", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, leak := range []string{
"secret-host", "private-remark", `"hostname"`, `"remark"`,
`"ip":`, `"ipv4"`, `"ipv6"`, "203.0.113.9", "198.51.100.4", "eth0",
} {
if strings.Contains(body, leak) {
t.Fatalf("anonymous frame leaks %q: %s", leak, body)
}
}
var frame struct {
Nodes []map[string]json.RawMessage `json:"nodes"`
Admin bool `json:"admin"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &frame); err != nil {
t.Fatalf("frame decode: %v", err)
}
if frame.Admin || len(frame.Nodes) != 1 {
t.Fatalf("anon frame: admin=%v nodes=%d", frame.Admin, len(frame.Nodes))
}
nodeBody, _ := json.Marshal(frame.Nodes[0])
assertKeys(t, keySet(t, nodeBody), anonNodeKeys)
var node map[string]any
_ = json.Unmarshal(nodeBody, &node)
if node["id"] != float64(pubID) || node["name"] != "PubNode" || node["online"] != true ||
node["month_rx"] != float64(33) || node["month_used"] != float64(77) ||
node["total_tx"] != float64(222) || node["price"] != float64(10) {
t.Fatalf("anon node values wrong: %v", node)
}
metricsObj, _ := json.Marshal(node["metrics"])
assertKeys(t, keySet(t, metricsObj), metricKeys)
// Metrics history for the public node; the private one refuses.
base := time.Now().Unix()
base -= base % 60
for _, ts := range []int64{base - 120, base - 60} {
if _, err := rig.store.DB().Exec(`INSERT INTO probe_metric
(agent_id, ts, cpu, mem_used, swap_used, disk_used, net_rx, net_tx,
net_rx_max, net_tx_max, tcp, udp, procs, cpu_max)
VALUES (?, ?, 10, 100, 0, 0, 1000, 2000, 1500, 2500, 3, 2, 10, 12)`,
pubID, ts); err != nil {
t.Fatalf("seed metric: %v", err)
}
}
rec = rig.do(http.MethodGet, "/api/nodes/"+itoa(pubID)+"/metrics?hours=1", nil, false)
if rec.Code != 200 {
t.Fatalf("metrics anon: %d (%s)", rec.Code, rec.Body.String())
}
var hist struct {
Metrics []map[string]json.RawMessage `json:"metrics"`
Ping []any `json:"ping"`
Probes map[string]string `json:"probes"`
Loss map[string]float64 `json:"loss"`
Step int64 `json:"step"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &hist); err != nil {
t.Fatalf("metrics decode: %v", err)
}
if hist.Step != 60 || len(hist.Metrics) != 2 || len(hist.Ping) != 0 ||
len(hist.Probes) != 0 || len(hist.Loss) != 0 {
t.Fatalf("metrics shape wrong: %+v", hist)
}
rowBody, _ := json.Marshal(hist.Metrics[0])
assertKeys(t, keySet(t, rowBody), historyKeys)
if rec := rig.do(http.MethodGet, "/api/nodes/"+itoa(privID)+"/metrics", nil, false); rec.Code != 401 {
t.Fatalf("metrics for private node anon: %d", rec.Code)
}
if rec := rig.do(http.MethodGet, "/api/nodes/abc/metrics", nil, false); rec.Code != 400 {
t.Fatalf("metrics bad id: %d", rec.Code)
}
// --- the admin audience ---
rec = rig.do(http.MethodGet, "/api/nodes", nil, true)
if rec.Code != 200 {
t.Fatalf("nodes admin: %d", rec.Code)
}
if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "no-store") {
t.Fatalf("admin frame cache header: %q", cc)
}
_ = json.Unmarshal(rec.Body.Bytes(), &frame)
if !frame.Admin || len(frame.Nodes) != 2 {
t.Fatalf("admin frame: admin=%v nodes=%d", frame.Admin, len(frame.Nodes))
}
adminBody := rec.Body.String()
for _, want := range []string{"secret-host-PubNode", "private-remark-PubNode", "203.0.113.9"} {
if !strings.Contains(adminBody, want) {
t.Fatalf("admin frame missing %q", want)
}
}
if rec := rig.do(http.MethodGet, "/api/nodes/"+itoa(privID)+"/metrics", nil, true); rec.Code != 200 {
t.Fatalf("metrics for private node admin: %d", rec.Code)
}
// --- theme hosting + API 404 discipline ---
themeDir := filepath.Join(rig.dbDir, "themes", "testtheme", "dist")
if err := os.MkdirAll(filepath.Join(themeDir, "assets"), 0o755); err != nil {
t.Fatal(err)
}
indexHTML := "<!doctype html><title>theme</title>"
if err := os.WriteFile(filepath.Join(themeDir, "index.html"), []byte(indexHTML), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(themeDir, "assets", "app-abc123.js"), []byte("console.log(1)"), 0o644); err != nil {
t.Fatal(err)
}
rig.setSetting(t, "theme", "testtheme")
rec = rig.do(http.MethodGet, "/", nil, false)
if rec.Code != 200 || rec.Body.String() != indexHTML {
t.Fatalf("theme root: %d %q", rec.Code, rec.Body.String())
}
etag := rec.Header().Get("ETag")
if etag == "" || !strings.Contains(rec.Header().Get("Cache-Control"), "no-cache") {
t.Fatalf("shell headers: etag=%q cc=%q", etag, rec.Header().Get("Cache-Control"))
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("If-None-Match", etag)
rec304 := httptest.NewRecorder()
rig.engine.ServeHTTP(rec304, req)
if rec304.Code != 304 {
t.Fatalf("etag revalidation: %d", rec304.Code)
}
if rec := rig.do(http.MethodGet, "/some/spa/route", nil, false); rec.Code != 200 || rec.Body.String() != indexHTML {
t.Fatalf("spa fallback: %d", rec.Code)
}
rec = rig.do(http.MethodGet, "/assets/app-abc123.js", nil, false)
if rec.Code != 200 || !strings.Contains(rec.Header().Get("Cache-Control"), "immutable") {
t.Fatalf("asset serving: %d cc=%q", rec.Code, rec.Header().Get("Cache-Control"))
}
if rec := rig.do(http.MethodGet, "/assets/missing.js", nil, false); rec.Code != 404 {
t.Fatalf("missing asset: %d", rec.Code)
}
rec = rig.do(http.MethodGet, "/api/nope", nil, false)
if rec.Code != 404 || !strings.Contains(rec.Body.String(), "没有这个接口:/api/nope") {
t.Fatalf("api 404: %d %q", rec.Code, rec.Body.String())
}
if rec := rig.do(http.MethodGet, "/panel/unknown", nil, false); rec.Code != 404 {
t.Fatalf("panel path must stay with the panel: %d", rec.Code)
}
// --- theme config round trip ---
if rec := rig.do(http.MethodGet, "/api/themes/testtheme/config", nil, false); rec.Code != 200 || rec.Body.String() != "{}" {
t.Fatalf("config default: %d %q", rec.Code, rec.Body.String())
}
if rec := rig.do(http.MethodPut, "/api/themes/testtheme/config", []byte(`{"accent":"red"}`), false); rec.Code != 401 {
t.Fatalf("config PUT anon: %d", rec.Code)
}
if rec := rig.do(http.MethodPut, "/api/themes/testtheme/config", []byte(`{"accent":"red"}`), true); rec.Code != 204 {
t.Fatalf("config PUT admin: %d (%s)", rec.Code, rec.Body.String())
}
if rec := rig.do(http.MethodGet, "/api/themes/testtheme/config", nil, false); rec.Body.String() != `{"accent":"red"}` {
t.Fatalf("config round trip: %q", rec.Body.String())
}
if rec := rig.do(http.MethodPut, "/api/themes/notinstalled/config", []byte(`{}`), true); rec.Code != 400 {
t.Fatalf("config PUT uninstalled: %d", rec.Code)
}
if rec := rig.do(http.MethodPut, "/api/themes/testtheme/config", []byte(`[1,2]`), true); rec.Code != 400 {
t.Fatalf("config PUT non-object: %d", rec.Code)
}
if rec := rig.do(http.MethodGet, "/api/themes/bad!short/config", nil, true); rec.Code != 404 {
t.Fatalf("config bad short: %d", rec.Code)
}
// --- probe-meta edit endpoints ---
metaPath := "/panel/api/agent/probe-meta/" + itoa(int64(privNodeID))
if rec := rig.do(http.MethodGet, metaPath, nil, false); rec.Code == 200 {
t.Fatal("probe-meta GET without login must not succeed")
}
rec = rig.do(http.MethodGet, metaPath, nil, true)
if rec.Code != 200 {
t.Fatalf("probe-meta GET: %d (%s)", rec.Code, rec.Body.String())
}
var env struct {
Success bool `json:"success"`
Obj struct {
Currency string `json:"currency"`
BillingCycle string `json:"billingCycle"`
Public bool `json:"public"`
} `json:"obj"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &env)
if !env.Success || env.Obj.Currency != "USD" || env.Obj.Public || env.Obj.BillingCycle != "monthly" {
t.Fatalf("probe-meta GET body: %s", rec.Body.String())
}
bad := []byte(`{"price":1,"currency":"USD","billingCycle":"monthly","trafficLimit":0,` +
`"trafficMode":"sum","trafficResetDay":32,"public":true}`)
rec = rig.do(http.MethodPut, metaPath, bad, true)
_ = json.Unmarshal(rec.Body.Bytes(), &env)
if env.Success {
t.Fatalf("reset day 32 must be refused: %s", rec.Body.String())
}
good := []byte(`{"price":3.5,"currency":"usd","billingCycle":"12m","trafficLimit":2000000000,` +
`"trafficMode":"max","trafficResetDay":15,"public":false,"ipv4Pin":" 203.0.113.9 "}`)
rec = rig.do(http.MethodPut, metaPath, good, true)
_ = json.Unmarshal(rec.Body.Bytes(), &env)
if !env.Success {
t.Fatalf("probe-meta PUT: %s", rec.Body.String())
}
rec = rig.do(http.MethodGet, metaPath, nil, true)
_ = json.Unmarshal(rec.Body.Bytes(), &env)
if env.Obj.Currency != "USD" || env.Obj.BillingCycle != "yearly" || env.Obj.Public {
t.Fatalf("probe-meta normalization not stored: %s", rec.Body.String())
}
saved, ok, err := probe.MetaFor(privID)
if err != nil || !ok || saved.IPv4Pin != "203.0.113.9" || saved.TrafficLimit != 2_000_000_000 {
t.Fatalf("stored meta: ok=%v err=%v %+v", ok, err, saved)
}
if rec := rig.do(http.MethodGet, "/panel/api/agent/probe-meta/999999", nil, true); rec.Code != 404 {
t.Fatalf("probe-meta unbound node: %d", rec.Code)
}
}
func TestProbeWebSocket(t *testing.T) {
rig := newProbeTestRig(t)
_, _ = rig.seedAgent(t, "WsNode", true)
rig.setSetting(t, "public_page", "on")
service.InvalidateProbeSnapshot()
srv := httptest.NewServer(rig.engine)
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/api/ws"
readFrame := func(conn *ws.Conn) (int, map[string]any) {
t.Helper()
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
mt, raw, err := conn.ReadMessage()
if err != nil {
t.Fatalf("ws read: %v", err)
}
if mt == ws.BinaryMessage {
zr, err := gzip.NewReader(bytes.NewReader(raw))
if err != nil {
t.Fatalf("gzip frame: %v", err)
}
raw, err = io.ReadAll(zr)
if err != nil {
t.Fatalf("gunzip frame: %v", err)
}
}
var frame map[string]any
if err := json.Unmarshal(raw, &frame); err != nil {
t.Fatalf("frame is not JSON: %v (%q)", err, raw)
}
return mt, frame
}
// Anonymous text frame.
conn, _, err := ws.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("dial anon: %v", err)
}
mt, frame := readFrame(conn)
if mt != ws.TextMessage || frame["admin"] != false {
t.Fatalf("anon frame: type=%d admin=%v", mt, frame["admin"])
}
if nodes, ok := frame["nodes"].([]any); !ok || len(nodes) != 1 {
t.Fatalf("anon frame nodes: %v", frame["nodes"])
}
conn.Close()
// Anonymous gzip frame: binary, gunzips to the same shape.
conn, _, err = ws.DefaultDialer.Dial(wsURL+"?gzip=1", nil)
if err != nil {
t.Fatalf("dial gzip: %v", err)
}
mt, frame = readFrame(conn)
if mt != ws.BinaryMessage || frame["admin"] != false {
t.Fatalf("gzip frame: type=%d admin=%v", mt, frame["admin"])
}
conn.Close()
// Admin frame over the session cookie.
hdr := http.Header{}
for _, ck := range rig.cookies {
hdr.Add("Cookie", ck.String())
}
conn, _, err = ws.DefaultDialer.Dial(wsURL, hdr)
if err != nil {
t.Fatalf("dial admin: %v", err)
}
_, frame = readFrame(conn)
if frame["admin"] != true {
t.Fatalf("admin frame: %v", frame["admin"])
}
conn.Close()
}
func itoa(v int64) string {
return strconv.FormatInt(v, 10)
}