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") } }