CI / go-test (push) Waiting to run
CI / postgres-durable-first (push) Waiting to run
CI / codegen (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / race (push) Waiting to run
CI / fuzz-smoke (push) Waiting to run
CI / golangci (push) Waiting to run
CI / frontend (push) Waiting to run
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions
Docs Deploy (GitHub Pages) / build (push) Canceled after 0s
Docs Deploy (GitHub Pages) / deploy (push) Canceled after 0s
602 lines
20 KiB
Go
602 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)
|
|
}
|
|
// The theme store (item 7 B5) lists an installed theme only when a
|
|
// valid theme.json manifest sits beside dist/ — the install pipeline
|
|
// always writes one, so the fixture must too, or the config PUT gate
|
|
// (ProbeThemeSelectable) rightly reports the theme as not installed.
|
|
manifest := `{"name":"Test Theme","short":"testtheme","description":"contract test fixture","version":"0.0.1","author":"tests","url":""}`
|
|
if err := os.WriteFile(filepath.Join(rig.dbDir, "themes", "testtheme", "theme.json"), []byte(manifest), 0o644); 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)
|
|
}
|