Files
aaaamoooo 80f1370f1b fix(panel-go): random ports roll once — sticky across edits (round-2 #7)
NormalizeNode re-selected ports whenever the request merely CONTAINED
port/serverId keys; the browser form always submits them (port=null in
random mode), so every save re-rolled both entry and exit ports. Now an
assigned port only moves on an explicit addressing change: mode switch to
random, a different fixed port value, or a server move where the port is
taken (fixed mode surfaces the 409). random->fixed pins the current port.

Regression: portlock_test.go (UI-shape payload saved twice, rename-only,
mode switches, fixed change/conflict, server move free/taken).
Golden re-captured: diff confined to nodePatchUiStyle/nodePatchJobs/
nodesAfterPatch/backup port leaves returning to the seed ports.
2026-10-20 20:48:58 +00:00

157 lines
5.3 KiB
Go

package main
// Regression tests for the port re-roll bug (round-2 item 7): a node whose
// entry/exit ports were assigned in "random" mode must keep them verbatim
// across later edits. The browser form always submits portMode + port (null
// in random mode) + serverId, so NormalizeNode must treat an edit as
// addressing-neutral unless the user explicitly changes the mode, supplies
// a different fixed port, or moves the node to a server where the port is
// already taken.
import (
"encoding/json"
"testing"
)
func portLockFixture() (*Data, *Node) {
exitID := "srv_exit"
exitProto := "shadowsocks-2022-aes256"
exitMode := "random"
exitPort := 31001
node := &Node{
ID: "nod_lock", Name: "N1", ServerID: "srv_entry", Topology: "forward",
Protocol: "vless-reality-vision", PortMode: "random", Port: 26522,
ExitServerID: &exitID, ExitProtocol: &exitProto,
ExitPortMode: &exitMode, ExitPort: &exitPort,
NetworkMode: "ipv4", RealityServerName: "www.tesla.com", RealityDestPort: 443,
}
data := &Data{
Servers: []*Server{
{ID: "srv_entry", Name: "Entry", PortRangeStart: 20000, PortRangeEnd: 50000},
{ID: "srv_exit", Name: "Exit", PortRangeStart: 20000, PortRangeEnd: 50000},
{ID: "srv_other", Name: "Other", PortRangeStart: 20000, PortRangeEnd: 50000},
},
Nodes: []*Node{node},
}
return data, node
}
// The exact payload shape the browser node form submits on save.
func uiNodeBody(name string) map[string]any {
return map[string]any{
"name": name, "serverId": "srv_entry", "protocol": "vless-reality-vision",
"portMode": "random", "port": nil, "networkMode": "ipv4", "topology": "forward",
"exitServerId": "srv_exit", "exitProtocol": "shadowsocks-2022-aes256",
"exitPortMode": "random", "exitPort": nil,
"realityServerName": "www.tesla.com", "realityDestPort": json.Number("443"),
}
}
func TestPortLockUIEditKeepsPortsVerbatim(t *testing.T) {
data, node := portLockFixture()
current := node
for i := 0; i < 2; i++ {
next, err := NormalizeNode(uiNodeBody("N1-renamed"), current, data)
if err != nil {
t.Fatalf("edit %d: %v", i, err)
}
if next.Port != 26522 {
t.Fatalf("edit %d: entry port re-rolled: got %d want 26522", i, next.Port)
}
if next.ExitPort == nil || *next.ExitPort != 31001 {
t.Fatalf("edit %d: exit port re-rolled: got %v want 31001", i, next.ExitPort)
}
current = next
data.Nodes[0] = next
}
}
func TestPortLockRenameOnlyKeepsPorts(t *testing.T) {
data, node := portLockFixture()
next, err := NormalizeNode(map[string]any{"name": "Other name"}, node, data)
if err != nil {
t.Fatalf("rename: %v", err)
}
if next.Port != 26522 || next.ExitPort == nil || *next.ExitPort != 31001 {
t.Fatalf("rename moved ports: port=%d exit=%v", next.Port, next.ExitPort)
}
}
func TestPortLockRandomToFixedPinsCurrentPort(t *testing.T) {
data, node := portLockFixture()
body := uiNodeBody("N1")
body["portMode"] = "fixed" // port key stays null, as the disabled input submits nothing
next, err := NormalizeNode(body, node, data)
if err != nil {
t.Fatalf("mode switch: %v", err)
}
if next.PortMode != "fixed" || next.Port != 26522 {
t.Fatalf("random->fixed should pin current port: mode=%s port=%d", next.PortMode, next.Port)
}
}
func TestPortLockFixedExplicitPortChangeAndConflict(t *testing.T) {
data, node := portLockFixture()
node.PortMode = "fixed"
body := uiNodeBody("N1")
body["portMode"] = "fixed"
body["port"] = json.Number("27000")
next, err := NormalizeNode(body, node, data)
if err != nil {
t.Fatalf("fixed change: %v", err)
}
if next.Port != 27000 {
t.Fatalf("fixed explicit port not applied: got %d", next.Port)
}
// Occupied port on the same server must 409.
other := &Node{ID: "nod_other", ServerID: "srv_entry", Port: 28000}
data.Nodes = append(data.Nodes, other)
body["port"] = json.Number("28000")
if _, err := NormalizeNode(body, next, data); err == nil {
t.Fatalf("expected conflict error for occupied fixed port")
}
}
func TestPortLockFixedToRandomRerolls(t *testing.T) {
data, node := portLockFixture()
node.PortMode = "fixed"
body := uiNodeBody("N1") // portMode random in body
next, err := NormalizeNode(body, node, data)
if err != nil {
t.Fatalf("fixed->random: %v", err)
}
if next.Port < 20000 || next.Port > 50000 {
t.Fatalf("re-rolled port out of range: %d", next.Port)
}
}
func TestPortLockServerMoveKeepsFreePortAndRerollsTaken(t *testing.T) {
data, node := portLockFixture()
body := uiNodeBody("N1")
body["serverId"] = "srv_other"
next, err := NormalizeNode(body, node, data)
if err != nil {
t.Fatalf("server move (free): %v", err)
}
if next.Port != 26522 {
t.Fatalf("server move should keep free port: got %d", next.Port)
}
// Occupy 26522 on the target server: random mode must roll a replacement.
blocker := &Node{ID: "nod_blocker", ServerID: "srv_other", Port: 26522}
data.Nodes = append(data.Nodes, blocker)
next2, err := NormalizeNode(body, node, data)
if err != nil {
t.Fatalf("server move (taken): %v", err)
}
if next2.Port == 26522 {
t.Fatalf("server move kept a taken port")
}
// Fixed mode must surface the conflict instead of silently moving.
node.PortMode = "fixed"
body["portMode"] = "fixed"
body["port"] = json.Number("26522")
if _, err := NormalizeNode(body, node, data); err == nil {
t.Fatalf("expected conflict error when moving fixed port onto a taken port")
}
}