fix(ui): edit-mode wizard allows keeping the same local port

Previously, editing a forward and keeping its local port unchanged
failed the wizard's port-availability check: the in-config scan
found the forward's own entry and reported '✗ Port N already
assigned to <self.ID>'. Users had to pick a different port,
edit, then change back.

checkPortCmd now accepts an excludeID. The wizard passes
wizard.originalID when isEditing so the forward being edited is
ignored during the in-config conflict scan. The OS-level port
check is unchanged (still catches actual port collisions).

New regression test: TestCheckPortCmd_ExcludeID_AllowsKeepingOwnPort.
This commit is contained in:
2026-05-06 12:45:35 +01:00
parent bfe541565b
commit dbc7830546
3 changed files with 59 additions and 12 deletions
+39 -3
View File
@@ -162,7 +162,7 @@ func TestCheckPortCmd_PortAvailability(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// Test checking a random high port that should be available // Test checking a random high port that should be available
cmd := checkPortCmd(59999, configPath) cmd := checkPortCmd(59999, configPath, "")
msg := cmd() msg := cmd()
portMsg, ok := msg.(PortCheckedMsg) portMsg, ok := msg.(PortCheckedMsg)
@@ -192,7 +192,7 @@ func TestCheckPortCmd_ConfigConflict(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// Test checking port that's already in config // Test checking port that's already in config
cmd := checkPortCmd(8080, configPath) cmd := checkPortCmd(8080, configPath, "")
msg := cmd() msg := cmd()
portMsg, ok := msg.(PortCheckedMsg) portMsg, ok := msg.(PortCheckedMsg)
@@ -202,10 +202,46 @@ func TestCheckPortCmd_ConfigConflict(t *testing.T) {
assert.Contains(t, portMsg.message, "already assigned") assert.Contains(t, portMsg.message, "already assigned")
} }
// TestCheckPortCmd_ExcludeID_AllowsKeepingOwnPort verifies that in edit mode
// (excludeID set to the forward's own ID), the wizard does not falsely report
// the same local port as already in use by the forward being edited.
func TestCheckPortCmd_ExcludeID_AllowsKeepingOwnPort(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".kportal.yaml")
configContent := `contexts:
- name: test-ctx
namespaces:
- name: default
forwards:
- resource: pod/my-app
port: 80
localPort: 8080
`
err := os.WriteFile(configPath, []byte(configContent), 0600)
require.NoError(t, err)
// The forward's ID format is "<context>/<namespace>/<resource>:<port>".
excludeID := "test-ctx/default/pod/my-app:8080"
cmd := checkPortCmd(8080, configPath, excludeID)
msg := cmd()
portMsg, ok := msg.(PortCheckedMsg)
require.True(t, ok, "Expected PortCheckedMsg")
assert.Equal(t, 8080, portMsg.port)
// The config-conflict path must skip the excluded ID. The OS-level port
// availability check still runs, so the result depends on whether 8080 is
// in use by some other process — the relevant assertion is that the
// message does NOT mention "already assigned" (which is the config check).
assert.NotContains(t, portMsg.message, "already assigned",
"excludeID should suppress the config self-conflict, but got %q", portMsg.message)
}
// TestCheckPortCmd_InvalidConfig tests behavior with invalid config file // TestCheckPortCmd_InvalidConfig tests behavior with invalid config file
func TestCheckPortCmd_InvalidConfig(t *testing.T) { func TestCheckPortCmd_InvalidConfig(t *testing.T) {
// Use a non-existent config path // Use a non-existent config path
cmd := checkPortCmd(59998, "/nonexistent/path/.kportal.yaml") cmd := checkPortCmd(59998, "/nonexistent/path/.kportal.yaml", "")
msg := cmd() msg := cmd()
portMsg, ok := msg.(PortCheckedMsg) portMsg, ok := msg.(PortCheckedMsg)
+15 -8
View File
@@ -145,8 +145,11 @@ func validateSelectorCmd(discovery *k8s.Discovery, contextName, namespace, selec
} }
} }
// checkPortCmd checks if a local port is available // checkPortCmd checks if a local port is available.
func checkPortCmd(port int, configPath string) tea.Cmd { // excludeID, when non-empty, is the ID of a forward to ignore during the
// in-config conflict scan. Used in edit mode so the wizard does not flag the
// forward being edited as conflicting with itself.
func checkPortCmd(port int, configPath, excludeID string) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
// First check if port is already in the configuration // First check if port is already in the configuration
cfg, err := config.LoadConfig(configPath) cfg, err := config.LoadConfig(configPath)
@@ -154,12 +157,16 @@ func checkPortCmd(port int, configPath string) tea.Cmd {
// Check all forwards in config for this port // Check all forwards in config for this port
allForwards := cfg.GetAllForwards() allForwards := cfg.GetAllForwards()
for _, fwd := range allForwards { for _, fwd := range allForwards {
if fwd.LocalPort == port { if fwd.LocalPort != port {
return PortCheckedMsg{ continue
port: port, }
available: false, if excludeID != "" && fwd.ID() == excludeID {
message: fmt.Sprintf("✗ Port %d already assigned to %s", port, fwd.ID()), continue
} }
return PortCheckedMsg{
port: port,
available: false,
message: fmt.Sprintf("✗ Port %d already assigned to %s", port, fwd.ID()),
} }
} }
} }
+5 -1
View File
@@ -656,7 +656,11 @@ func (m model) handleAddWizardEnter() (tea.Model, tea.Cmd) {
wizard.localPort = port wizard.localPort = port
wizard.loading = true wizard.loading = true
wizard.error = nil wizard.error = nil
return m, checkPortCmd(port, m.ui.configPath) excludeID := ""
if wizard.isEditing {
excludeID = wizard.originalID
}
return m, checkPortCmd(port, m.ui.configPath, excludeID)
} }
case StepConfirmation: case StepConfirmation: