package service import ( "path/filepath" "testing" "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/web/service/probe" ) // Node deletion must cascade to the node's own agent bindings and to // every probe row of those agents, and to nothing else. The key trap: // agent rows link by node_id (the panel node id), while probe rows // link by agent row id — matching probe rows on the node id would // delete a different node's probe data (Oracle-JP: node 33's agent // row id was 20, so node-id matching would have struck agent 33). func TestNodeDelete_CascadesAgentAndProbeRows(t *testing.T) { db := initTrafficTestDB(t) st, err := probe.Open(filepath.Join(t.TempDir(), "probe.db")) if err != nil { t.Fatalf("open probe store: %v", err) } t.Cleanup(func() { _ = st.Close() }) var probeErr error oldProbeDelete := deleteProbeDataForAgent deleteProbeDataForAgent = func(id int64) { if err := st.DeleteAgentData(id); err != nil && probeErr == nil { probeErr = err } } t.Cleanup(func() { deleteProbeDataForAgent = oldProbeDelete }) nodeA := &model.Node{Name: "cascade-a"} nodeB := &model.Node{Name: "cascade-b"} if err := db.Create(nodeA).Error; err != nil { t.Fatalf("create node A: %v", err) } if err := db.Create(nodeB).Error; err != nil { t.Fatalf("create node B: %v", err) } mkAgent := func(name string, nodeID *int) *model.AgentNode { ag := &model.AgentNode{TokenHash: "hash-" + name, Name: name, NodeID: nodeID} if err := db.Create(ag).Error; err != nil { t.Fatalf("create agent %s: %v", name, err) } return ag } a1 := mkAgent("cascade-a1", &nodeA.Id) a2 := mkAgent("cascade-a2", &nodeA.Id) b1 := mkAgent("cascade-b1", &nodeB.Id) u1 := mkAgent("cascade-unbound", nil) if err := db.Create(&model.NodeClientTraffic{NodeId: nodeA.Id, Email: "a@example.com"}).Error; err != nil { t.Fatalf("seed baseline A: %v", err) } if err := db.Create(&model.NodeClientTraffic{NodeId: nodeB.Id, Email: "b@example.com"}).Error; err != nil { t.Fatalf("seed baseline B: %v", err) } seedProbe := func(ag *model.AgentNode, price float64) { t.Helper() exec := func(q string, args ...any) { t.Helper() if _, err := st.DB().Exec(q, args...); err != nil { t.Fatalf("seed probe agent %d: %v", ag.Id, err) } } exec(`INSERT INTO probe_meta (agent_id, price) VALUES (?, ?)`, ag.Id, price) exec(`INSERT INTO probe_traffic (agent_id, total_rx) VALUES (?, 100)`, ag.Id) exec(`INSERT INTO probe_metric (agent_id, ts, cpu, mem_used, swap_used, disk_used, net_rx, net_tx, tcp, udp, procs) VALUES (?, 1000, 1, 1, 1, 1, 1, 1, 1, 1, 1)`, ag.Id) exec(`INSERT INTO probe_ping_node (task_id, agent_id) VALUES (7001, ?)`, ag.Id) exec(`INSERT INTO probe_ping_record (agent_id, ts, task_id, latency) VALUES (?, 1000, 7001, 5)`, ag.Id) } if _, err := st.DB().Exec(`INSERT INTO probe_ping_task (id, name, target) VALUES (7001, 'cascade-task', 'example.com')`); err != nil { t.Fatalf("seed ping task: %v", err) } seedProbe(a1, 11) seedProbe(a2, 22) seedProbe(b1, 33) svc := &NodeService{} if err := svc.Delete(nodeA.Id); err != nil { t.Fatalf("Delete node A: %v", err) } if probeErr != nil { t.Fatalf("probe cascade: %v", probeErr) } countPanel := func(table, where string, args ...any) int64 { t.Helper() var n int64 if err := db.Table(table).Where(where, args...).Count(&n).Error; err != nil { t.Fatalf("count %s: %v", table, err) } return n } countProbe := func(table string, agentID int) int { t.Helper() var n int if err := st.DB().QueryRow(`SELECT COUNT(*) FROM `+table+` WHERE agent_id = ?`, agentID).Scan(&n); err != nil { t.Fatalf("count probe %s agent %d: %v", table, agentID, err) } return n } if got := countPanel("nodes", "id = ?", nodeA.Id); got != 0 { t.Fatalf("node A rows = %d, want 0", got) } if got := countPanel("nodes", "id = ?", nodeB.Id); got != 1 { t.Fatalf("node B rows = %d, want 1", got) } if got := countPanel("agent_nodes", "node_id = ?", nodeA.Id); got != 0 { t.Fatalf("node A agent rows = %d, want 0", got) } if got := countPanel("agent_nodes", "id = ?", b1.Id); got != 1 { t.Fatalf("node B agent row = %d, want 1", got) } if got := countPanel("agent_nodes", "id = ?", u1.Id); got != 1 { t.Fatalf("unbound agent row = %d, want 1", got) } if got := countPanel("node_client_traffics", "node_id = ?", nodeA.Id); got != 0 { t.Fatalf("node A baselines = %d, want 0", got) } if got := countPanel("node_client_traffics", "node_id = ?", nodeB.Id); got != 1 { t.Fatalf("node B baselines = %d, want 1", got) } // Deleted agents: every probe table empty. Surviving agent: every // probe row intact, with its own values (not just a nonzero count). for _, table := range []string{"probe_meta", "probe_traffic", "probe_metric", "probe_ping_node", "probe_ping_record"} { for _, ag := range []*model.AgentNode{a1, a2} { if got := countProbe(table, ag.Id); got != 0 { t.Fatalf("%s rows for deleted agent %d = %d, want 0", table, ag.Id, got) } } if got := countProbe(table, b1.Id); got != 1 { t.Fatalf("%s rows for surviving agent %d = %d, want 1", table, b1.Id, got) } } var price float64 if err := st.DB().QueryRow(`SELECT price FROM probe_meta WHERE agent_id = ?`, b1.Id).Scan(&price); err != nil || price != 33 { t.Fatalf("surviving agent meta price = %v err=%v, want 33", price, err) } var taskCnt int if err := st.DB().QueryRow(`SELECT COUNT(*) FROM probe_ping_task WHERE id = 7001`).Scan(&taskCnt); err != nil || taskCnt != 1 { t.Fatalf("ping task rows = %d err=%v, want 1 (tasks are global, only assignments cascade)", taskCnt, err) } } // A blocked delete (inbounds still attached) must touch neither panel // agent rows nor probe rows: the probe cascade runs only after the // panel transaction commits. func TestNodeDelete_BlockedKeepsAgentAndProbeRows(t *testing.T) { db := initTrafficTestDB(t) st, err := probe.Open(filepath.Join(t.TempDir(), "probe.db")) if err != nil { t.Fatalf("open probe store: %v", err) } t.Cleanup(func() { _ = st.Close() }) var probeCalls []int64 oldProbeDelete := deleteProbeDataForAgent deleteProbeDataForAgent = func(id int64) { probeCalls = append(probeCalls, id) } t.Cleanup(func() { deleteProbeDataForAgent = oldProbeDelete }) node := &model.Node{Name: "blocked-node"} if err := db.Create(node).Error; err != nil { t.Fatalf("create node: %v", err) } ag := &model.AgentNode{TokenHash: "hash-blocked", Name: "blocked-agent", NodeID: &node.Id} if err := db.Create(ag).Error; err != nil { t.Fatalf("create agent: %v", err) } if _, err := st.DB().Exec(`INSERT INTO probe_meta (agent_id, price) VALUES (?, 44)`, ag.Id); err != nil { t.Fatalf("seed probe meta: %v", err) } createNodeInbound(t, db, node.Id, "blocked-in-443", 443) svc := &NodeService{} if err := svc.Delete(node.Id); err == nil { t.Fatal("Delete should fail while an inbound is still attached") } if len(probeCalls) != 0 { t.Fatalf("probe cascade ran on blocked delete: %v", probeCalls) } var nodeCnt, agCnt int64 db.Model(&model.Node{}).Where("id = ?", node.Id).Count(&nodeCnt) db.Model(&model.AgentNode{}).Where("id = ?", ag.Id).Count(&agCnt) var metaCnt int if err := st.DB().QueryRow(`SELECT COUNT(*) FROM probe_meta WHERE agent_id = ?`, ag.Id).Scan(&metaCnt); err != nil { t.Fatalf("count probe meta: %v", err) } if nodeCnt != 1 || agCnt != 1 || metaCnt != 1 { t.Fatalf("after blocked delete: node=%d agent=%d probeMeta=%d, want 1/1/1", nodeCnt, agCnt, metaCnt) } }