Skip to content

Commit e84af9a

Browse files
committed
feat: add IP address validation and duplicate alias checking
- Add IsIPAddress public function for shared IP validation - Add GetFieldValidatorsWithContext for context-aware validation - Implement duplicate alias validation with support for edit mode - Share IP validation logic between alias generation and validation - Add comprehensive test coverage for IPv4 and IPv6 addresses - Add tests for duplicate alias validation scenarios
1 parent bc04cbf commit e84af9a

File tree

3 files changed

+241
-1
lines changed

3 files changed

+241
-1
lines changed

internal/adapters/ui/validation.go

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,12 +131,20 @@ const invalidAddressChars = "@#$%^&()=+{}|\\;:'\"<>,?/"
131131

132132
// GetFieldValidators returns validation rules for SSH configuration fields
133133
func GetFieldValidators() map[string]fieldValidator {
134+
return GetFieldValidatorsWithContext("", nil)
135+
}
136+
137+
// GetFieldValidatorsWithContext returns validation rules with context-aware validators
138+
// originalAlias is used to exclude the current alias when editing
139+
// existingAliases is used to check for duplicates
140+
func GetFieldValidatorsWithContext(originalAlias string, existingAliases []string) map[string]fieldValidator {
134141
validators := make(map[string]fieldValidator)
135142

136143
// Basic fields
137144
validators["Alias"] = fieldValidator{
138145
Required: true,
139146
Pattern: regexp.MustCompile(`^[a-zA-Z0-9._-]+$`),
147+
Validate: createAliasValidator(originalAlias, existingAliases),
140148
Message: "Alias is required and can only contain letters, numbers, dots, hyphens, and underscores",
141149
}
142150
validators["Host"] = fieldValidator{
@@ -447,6 +455,35 @@ func validateKnownHostsFiles(files string) error {
447455
return validateFilePaths(files, " ")
448456
}
449457

458+
// createAliasValidator creates a validator for alias uniqueness
459+
func createAliasValidator(originalAlias string, existingAliases []string) func(string) error {
460+
return func(alias string) error {
461+
// Skip duplicate check if no existing aliases provided
462+
if len(existingAliases) == 0 {
463+
return nil
464+
}
465+
466+
// Skip check if alias hasn't changed (edit mode)
467+
if originalAlias != "" && alias == originalAlias {
468+
return nil
469+
}
470+
471+
// Check for duplicates
472+
for _, existing := range existingAliases {
473+
if existing == alias {
474+
return fmt.Errorf("alias '%s' already exists", alias)
475+
}
476+
}
477+
478+
return nil
479+
}
480+
}
481+
482+
// IsIPAddress checks if the given string is a valid IP address (IPv4 or IPv6)
483+
func IsIPAddress(host string) bool {
484+
return net.ParseIP(host) != nil
485+
}
486+
450487
// validateHost validates a hostname or IP address
451488
func validateHost(host string) error {
452489
if host == "" {
@@ -459,7 +496,7 @@ func validateHost(host string) error {
459496
}
460497

461498
// Try to parse as IP address first
462-
if net.ParseIP(host) != nil {
499+
if IsIPAddress(host) {
463500
return nil
464501
}
465502

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
// Copyright 2025.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package ui
16+
17+
import "testing"
18+
19+
func TestIsIPAddress(t *testing.T) {
20+
tests := []struct {
21+
name string
22+
input string
23+
want bool
24+
}{
25+
// Valid IPv4 addresses
26+
{
27+
name: "valid IPv4",
28+
input: "192.168.1.1",
29+
want: true,
30+
},
31+
{
32+
name: "valid IPv4 - localhost",
33+
input: "127.0.0.1",
34+
want: true,
35+
},
36+
{
37+
name: "valid IPv4 - zeros",
38+
input: "0.0.0.0",
39+
want: true,
40+
},
41+
{
42+
name: "valid IPv4 - max",
43+
input: "255.255.255.255",
44+
want: true,
45+
},
46+
// Valid IPv6 addresses
47+
{
48+
name: "valid IPv6 - full",
49+
input: "2001:0db8:85a3:0000:0000:8a2e:0370:7334",
50+
want: true,
51+
},
52+
{
53+
name: "valid IPv6 - compressed",
54+
input: "2001:db8:85a3::8a2e:370:7334",
55+
want: true,
56+
},
57+
{
58+
name: "valid IPv6 - localhost",
59+
input: "::1",
60+
want: true,
61+
},
62+
{
63+
name: "valid IPv6 - all zeros",
64+
input: "::",
65+
want: true,
66+
},
67+
// Invalid addresses
68+
{
69+
name: "invalid - hostname",
70+
input: "example.com",
71+
want: false,
72+
},
73+
{
74+
name: "invalid - hostname with subdomain",
75+
input: "api.example.com",
76+
want: false,
77+
},
78+
{
79+
name: "invalid - IPv4 out of range",
80+
input: "256.256.256.256",
81+
want: false,
82+
},
83+
{
84+
name: "invalid - IPv4 with letters",
85+
input: "192.168.1.a",
86+
want: false,
87+
},
88+
{
89+
name: "invalid - too many octets",
90+
input: "192.168.1.1.1",
91+
want: false,
92+
},
93+
{
94+
name: "invalid - too few octets",
95+
input: "192.168.1",
96+
want: false,
97+
},
98+
{
99+
name: "invalid - empty string",
100+
input: "",
101+
want: false,
102+
},
103+
{
104+
name: "invalid - just dots",
105+
input: "...",
106+
want: false,
107+
},
108+
{
109+
name: "invalid - localhost name",
110+
input: "localhost",
111+
want: false,
112+
},
113+
}
114+
115+
for _, tt := range tests {
116+
t.Run(tt.name, func(t *testing.T) {
117+
got := IsIPAddress(tt.input)
118+
if got != tt.want {
119+
t.Errorf("IsIPAddress(%q) = %v, want %v", tt.input, got, tt.want)
120+
}
121+
})
122+
}
123+
}

internal/adapters/ui/validation_test.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ package ui
1717
import (
1818
"os"
1919
"path/filepath"
20+
"strings"
2021
"testing"
2122
)
2223

@@ -327,3 +328,82 @@ func TestValidationState_Clear(t *testing.T) {
327328
t.Errorf("Expected error count to be 0, got %d", state.GetErrorCount())
328329
}
329330
}
331+
332+
func TestAliasValidation(t *testing.T) {
333+
tests := []struct {
334+
name string
335+
alias string
336+
originalAlias string
337+
existingAliases []string
338+
wantErr bool
339+
errContains string
340+
}{
341+
{
342+
name: "valid new alias",
343+
alias: "newserver",
344+
originalAlias: "",
345+
existingAliases: []string{"server1", "server2"},
346+
wantErr: false,
347+
},
348+
{
349+
name: "duplicate alias",
350+
alias: "server1",
351+
originalAlias: "",
352+
existingAliases: []string{"server1", "server2"},
353+
wantErr: true,
354+
errContains: "already exists",
355+
},
356+
{
357+
name: "edit mode - same alias allowed",
358+
alias: "server1",
359+
originalAlias: "server1",
360+
existingAliases: []string{"server1", "server2"},
361+
wantErr: false,
362+
},
363+
{
364+
name: "edit mode - changed to duplicate",
365+
alias: "server2",
366+
originalAlias: "server1",
367+
existingAliases: []string{"server1", "server2"},
368+
wantErr: true,
369+
errContains: "already exists",
370+
},
371+
{
372+
name: "no existing aliases",
373+
alias: "anyserver",
374+
originalAlias: "",
375+
existingAliases: nil,
376+
wantErr: false,
377+
},
378+
{
379+
name: "empty existing aliases",
380+
alias: "anyserver",
381+
originalAlias: "",
382+
existingAliases: []string{},
383+
wantErr: false,
384+
},
385+
}
386+
387+
for _, tt := range tests {
388+
t.Run(tt.name, func(t *testing.T) {
389+
validators := GetFieldValidatorsWithContext(tt.originalAlias, tt.existingAliases)
390+
aliasValidator := validators["Alias"]
391+
392+
var err error
393+
if aliasValidator.Validate != nil {
394+
err = aliasValidator.Validate(tt.alias)
395+
}
396+
397+
if (err != nil) != tt.wantErr {
398+
t.Errorf("alias validation error = %v, wantErr %v", err, tt.wantErr)
399+
return
400+
}
401+
402+
if err != nil && tt.errContains != "" {
403+
if !strings.Contains(err.Error(), tt.errContains) {
404+
t.Errorf("error message = %v, want to contain %v", err.Error(), tt.errContains)
405+
}
406+
}
407+
})
408+
}
409+
}

0 commit comments

Comments
 (0)