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.
282 lines
9.4 KiB
Go
282 lines
9.4 KiB
Go
package controller
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"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/probe"
|
|
)
|
|
|
|
// Item 7 B4 controller tests: probe task management endpoints (hub
|
|
// validation chain, node-id translation, base-diff, reorder), the
|
|
// metrics series=ping read going live, and heartbeat ingestion of
|
|
// probeResults + config delivery of probe_tasks.
|
|
|
|
type envelope struct {
|
|
Success bool `json:"success"`
|
|
Msg string `json:"msg"`
|
|
Obj json.RawMessage `json:"obj"`
|
|
}
|
|
|
|
func decodeEnvelope(t *testing.T, body []byte) envelope {
|
|
t.Helper()
|
|
var env envelope
|
|
if err := json.Unmarshal(body, &env); err != nil {
|
|
t.Fatalf("envelope: %v (%s)", err, body)
|
|
}
|
|
return env
|
|
}
|
|
|
|
func cleanPingTasks(t *testing.T) {
|
|
t.Helper()
|
|
tasks, err := probe.PingTasks()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, task := range tasks {
|
|
if err := probe.DeletePingTask(task.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
const tasksPath = "/panel/api/agent/probe-tasks"
|
|
|
|
func TestProbeTasksAuthAndValidation(t *testing.T) {
|
|
rig := newProbeTestRig(t)
|
|
cleanPingTasks(t)
|
|
|
|
// Anonymous callers are refused by the group auth.
|
|
rec := rig.do(http.MethodPut, tasksPath, []byte(`{"name":"x","target":"1.1.1.1:443","interval":60}`), false)
|
|
if rec.Code == http.StatusOK && decodeEnvelope(t, rec.Body.Bytes()).Success {
|
|
t.Fatal("anonymous save succeeded")
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
body string
|
|
want string
|
|
}{
|
|
{"empty name", `{"name":" ","target":"1.1.1.1:443","interval":60}`, "请填写名称和目标"},
|
|
{"empty target", `{"name":"x","target":"","interval":60}`, "请填写名称和目标"},
|
|
{"long name", `{"name":"` + strings.Repeat("名", 65) + `","target":"1.1.1.1:443","interval":60}`, "名称最多 64 个字"},
|
|
{"no port", `{"name":"x","target":"1.1.1.1","interval":60}`, "目标要写成"},
|
|
{"empty port", `{"name":"x","target":"1.1.1.1:","interval":60}`, "目标要写成"},
|
|
{"port range", `{"name":"x","target":"1.1.1.1:65536","interval":60}`, "目标要写成"},
|
|
{"bare v6", `{"name":"x","target":"2606:4700:4700::1111:443","interval":60}`, "目标要写成"},
|
|
{"interval low", `{"name":"x","target":"1.1.1.1:443","interval":3}`, "间隔要在 5 到 3600 秒之间"},
|
|
{"interval high", `{"name":"x","target":"1.1.1.1:443","interval":3601}`, "间隔要在 5 到 3600 秒之间"},
|
|
{"long region", `{"name":"x","target":"1.1.1.1:443","interval":60,"region":"` + strings.Repeat("区", 14) + `"}`, "地区最多 13 个字"},
|
|
}
|
|
for _, tc := range cases {
|
|
rec := rig.do(http.MethodPut, tasksPath, []byte(tc.body), true)
|
|
env := decodeEnvelope(t, rec.Body.Bytes())
|
|
if env.Success {
|
|
t.Errorf("%s: save succeeded, want refusal", tc.name)
|
|
continue
|
|
}
|
|
if !strings.Contains(env.Msg, tc.want) {
|
|
t.Errorf("%s: msg %q, want fragment %q", tc.name, env.Msg, tc.want)
|
|
}
|
|
}
|
|
if tasks, _ := probe.PingTasks(); len(tasks) != 0 {
|
|
t.Fatalf("refused saves leaked %d tasks", len(tasks))
|
|
}
|
|
|
|
// Valid forms: bracketed v6, boundary interval, 13-rune region.
|
|
for _, body := range []string{
|
|
`{"name":"v6","target":"[2606:4700:4700::1111]:443","interval":5}`,
|
|
`{"name":"edge","target":"example.com:65535","interval":3600,"region":"` + strings.Repeat("区", 13) + `"}`,
|
|
} {
|
|
rec := rig.do(http.MethodPut, tasksPath, []byte(body), true)
|
|
if env := decodeEnvelope(t, rec.Body.Bytes()); !env.Success {
|
|
t.Fatalf("valid save refused: %s (%s)", env.Msg, body)
|
|
}
|
|
}
|
|
cleanPingTasks(t)
|
|
}
|
|
|
|
func TestProbeTasksNodeMappingAndLifecycle(t *testing.T) {
|
|
rig := newProbeTestRig(t)
|
|
cleanPingTasks(t)
|
|
agentID, nodeID := rig.seedAgent(t, "b4node", true)
|
|
|
|
// A node with no agent bound cannot take assignments.
|
|
db := database.GetDB()
|
|
lone := &model.Node{Name: "b4-lone"}
|
|
if err := db.Create(lone).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body := `{"name":"t1","target":"1.1.1.1:443","interval":60,"nodeIds":[` + itoa(int64(lone.Id)) + `]}`
|
|
rec := rig.do(http.MethodPut, tasksPath, []byte(body), true)
|
|
if env := decodeEnvelope(t, rec.Body.Bytes()); env.Success || !strings.Contains(env.Msg, "未绑定 agent") {
|
|
t.Fatalf("unbound node save: %+v", env)
|
|
}
|
|
|
|
// Create assigned to the bound node.
|
|
body = `{"name":"t1","target":"1.1.1.1:443","interval":60,"region":"亚洲","nodeIds":[` + itoa(int64(nodeID)) + `]}`
|
|
rec = rig.do(http.MethodPut, tasksPath, []byte(body), true)
|
|
env := decodeEnvelope(t, rec.Body.Bytes())
|
|
if !env.Success {
|
|
t.Fatalf("create: %s", env.Msg)
|
|
}
|
|
var created struct {
|
|
ID int64 `json:"id"`
|
|
}
|
|
if err := json.Unmarshal(env.Obj, &created); err != nil || created.ID == 0 {
|
|
t.Fatalf("create obj: %s", env.Obj)
|
|
}
|
|
refs := probe.PingTasksFor(agentID)
|
|
if len(refs) != 1 || refs[0].ID != created.ID {
|
|
t.Fatalf("agent view: %v", refs)
|
|
}
|
|
|
|
// The list speaks node ids.
|
|
rec = rig.do(http.MethodGet, tasksPath, nil, true)
|
|
env = decodeEnvelope(t, rec.Body.Bytes())
|
|
var list []probeTaskForm
|
|
if err := json.Unmarshal(env.Obj, &list); err != nil || len(list) != 1 {
|
|
t.Fatalf("list: %s", env.Obj)
|
|
}
|
|
if len(list[0].NodeIDs) != 1 || list[0].NodeIDs[0] != int64(nodeID) || list[0].Region != "亚洲" {
|
|
t.Fatalf("list form %+v", list[0])
|
|
}
|
|
|
|
// by-node edit: clear the assignment with a base diff.
|
|
byNode := `{"tasks":[],"base":[` + itoa64(created.ID) + `]}`
|
|
rec = rig.do(http.MethodPut, tasksPath+"/by-node/"+itoa(int64(nodeID)), []byte(byNode), true)
|
|
if env := decodeEnvelope(t, rec.Body.Bytes()); !env.Success {
|
|
t.Fatalf("by-node clear: %s", env.Msg)
|
|
}
|
|
if refs := probe.PingTasksFor(agentID); len(refs) != 0 {
|
|
t.Fatalf("after by-node clear: %v", refs)
|
|
}
|
|
// by-node on an unknown node 404s.
|
|
rec = rig.do(http.MethodPut, tasksPath+"/by-node/999999", []byte(`{"tasks":[],"base":[]}`), true)
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("by-node unknown node: %d", rec.Code)
|
|
}
|
|
|
|
// Reorder: second task, flip, duplicates refused.
|
|
rec = rig.do(http.MethodPut, tasksPath, []byte(`{"name":"t2","target":"8.8.8.8:53","interval":30}`), true)
|
|
env = decodeEnvelope(t, rec.Body.Bytes())
|
|
var created2 struct {
|
|
ID int64 `json:"id"`
|
|
}
|
|
_ = json.Unmarshal(env.Obj, &created2)
|
|
flip := `{"ids":[` + itoa64(created2.ID) + `,` + itoa64(created.ID) + `]}`
|
|
rec = rig.do(http.MethodPost, tasksPath+"/reorder", []byte(flip), true)
|
|
if env := decodeEnvelope(t, rec.Body.Bytes()); !env.Success {
|
|
t.Fatalf("reorder: %s", env.Msg)
|
|
}
|
|
dup := `{"ids":[` + itoa64(created.ID) + `,` + itoa64(created.ID) + `]}`
|
|
rec = rig.do(http.MethodPost, tasksPath+"/reorder", []byte(dup), true)
|
|
if env := decodeEnvelope(t, rec.Body.Bytes()); env.Success {
|
|
t.Fatal("duplicate reorder accepted")
|
|
}
|
|
tasks, _ := probe.PingTasks()
|
|
if len(tasks) != 2 || tasks[0].ID != created2.ID {
|
|
t.Fatalf("order after flip %+v", tasks)
|
|
}
|
|
|
|
// Delete clears the agent's view too.
|
|
rec = rig.do(http.MethodDelete, tasksPath+"/"+itoa64(created.ID), nil, true)
|
|
if env := decodeEnvelope(t, rec.Body.Bytes()); !env.Success {
|
|
t.Fatalf("delete: %s", env.Msg)
|
|
}
|
|
if tasks, _ := probe.PingTasks(); len(tasks) != 1 {
|
|
t.Fatalf("after delete %v", tasks)
|
|
}
|
|
cleanPingTasks(t)
|
|
}
|
|
|
|
// series=ping serves real rows now: medians from filed samples, the
|
|
// probes label map, and whole-window loss for a fully-missed task.
|
|
func TestProbeMetricsPingSeriesLive(t *testing.T) {
|
|
rig := newProbeTestRig(t)
|
|
cleanPingTasks(t)
|
|
rig.setSetting(t, "public_page", "on")
|
|
agentID, _ := rig.seedAgent(t, "b4metrics", true)
|
|
|
|
good, err := probe.SavePingTask(probe.PingTask{Name: "good", Target: "1.1.1.1:443", Interval: 60, AgentIDs: []int64{agentID}}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
bad, err := probe.SavePingTask(probe.PingTask{Name: "bad", Target: "127.0.0.1:1", Interval: 60, AgentIDs: []int64{agentID}}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
st := probe.Default()
|
|
now := time.Now().Unix()
|
|
for i := 0; i < 3; i++ {
|
|
if err := st.InsertPings(agentID, []probe.PingResult{{TaskID: good, LatencyMs: 10}}, now-int64(i)*10); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := st.InsertPings(agentID, []probe.PingResult{{TaskID: bad, LatencyMs: -1}}, now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
rec := rig.do(http.MethodGet, "/api/nodes/"+itoa64(agentID)+"/metrics?series=ping&hours=1", nil, false)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("metrics: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
var resp struct {
|
|
Ping []struct {
|
|
TaskID int64 `json:"task_id"`
|
|
Latency *int64 `json:"latency"`
|
|
Loss *int64 `json:"loss"`
|
|
} `json:"ping"`
|
|
Probes map[string]string `json:"probes"`
|
|
Loss map[string]float64 `json:"loss"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var goodRow, badRow bool
|
|
for _, row := range resp.Ping {
|
|
switch row.TaskID {
|
|
case good:
|
|
goodRow = true
|
|
if row.Latency == nil || *row.Latency != 10 {
|
|
t.Fatalf("good latency %v", row.Latency)
|
|
}
|
|
case bad:
|
|
badRow = true
|
|
if row.Latency != nil {
|
|
t.Fatalf("bad latency %v, want null", *row.Latency)
|
|
}
|
|
if row.Loss == nil || *row.Loss != 100 {
|
|
t.Fatalf("bad bucket loss %v", row.Loss)
|
|
}
|
|
}
|
|
}
|
|
if !goodRow || !badRow {
|
|
t.Fatalf("rows %+v", resp.Ping)
|
|
}
|
|
if resp.Probes[itoa64(good)] != "good" || resp.Probes[itoa64(bad)] != "bad" {
|
|
t.Fatalf("probes %v", resp.Probes)
|
|
}
|
|
if resp.Loss[itoa64(bad)] != 100.0 {
|
|
t.Fatalf("window loss %v", resp.Loss)
|
|
}
|
|
if _, ok := resp.Loss[itoa64(good)]; ok {
|
|
t.Fatalf("good task in loss map %v", resp.Loss)
|
|
}
|
|
cleanPingTasks(t)
|
|
}
|
|
|
|
func itoa64(n int64) string {
|
|
return strings.TrimSpace(jsonNumber(n))
|
|
}
|
|
|
|
func jsonNumber(n int64) string {
|
|
b, _ := json.Marshal(n)
|
|
return string(b)
|
|
}
|