Files
ssh-netbox-wrapper/internal/setup/wizard_test.go
Sebastian UnterschützandClaude Opus 5.5 a950a82552 feat: hostname resolver strategy and static hosts
Add the hostname resolver strategy. It connects by the NetBox name
instead of an IP, with resolver.hostname_domain appended to names
without a dot. Its position in the chain decides whether it is always
used or only as a fallback (e.g. [primary_ip, hostname]); names that
are not valid DNS hostnames fall through to the next strategy.

Add static hosts under hosts: for machines that are not in NetBox,
with address, user, port, tags, and their own SSH options. They are
checked before NetBox (by name or shortcut), never expire, survive
cache refresh and clear, and work without any NetBox configured. The
cache overlays them after every load and stores only their last use,
so they appear in completion, aliases, the TUI and its recent list.
SSH options merge global, static host, then rules; rules can match
kind: static. Manage them with netssh hosts list|add|remove and in
netssh configure, which can now also continue without NetBox.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 23:46:13 +01:00

620 lines
19 KiB
Go

package setup
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
)
func TestSave_WritesFile(t *testing.T) {
dir := t.TempDir()
orig := os.Getenv("XDG_CONFIG_HOME")
os.Setenv("XDG_CONFIG_HOME", dir)
defer os.Setenv("XDG_CONFIG_HOME", orig)
cfg := config.Config{
NetBox: config.NetBoxConfig{
URL: "https://netbox.example.com",
Token: "nbt_abc123",
TokenVersion: 2,
},
SSH: config.SSHConfig{DefaultUser: "admin"},
Resolver: config.ResolverConfig{
Strategies: []string{"primary_ip", "management_subnet"},
ManagementSubnets: []string{"10.0.0.0/8"},
},
Cache: config.CacheConfig{TTL: 3600},
}
if err := save(cfg); err != nil {
t.Fatalf("save: %v", err)
}
data, err := os.ReadFile(filepath.Join(dir, "netssh.yaml"))
if err != nil {
t.Fatalf("reading saved file: %v", err)
}
content := string(data)
for _, want := range []string{
`"https://netbox.example.com"`,
`"nbt_abc123"`,
`token_version: 2`,
`- primary_ip`,
`- management_subnet`,
`- 10.0.0.0/8`,
`ttl: 3600`,
`"admin"`,
} {
if !strings.Contains(content, want) {
t.Errorf("saved config missing %q\nfull content:\n%s", want, content)
}
}
}
func TestSave_FilePermissions(t *testing.T) {
dir := t.TempDir()
orig := os.Getenv("XDG_CONFIG_HOME")
os.Setenv("XDG_CONFIG_HOME", dir)
defer os.Setenv("XDG_CONFIG_HOME", orig)
if err := save(config.Config{
NetBox: config.NetBoxConfig{URL: "http://x", Token: "t", TokenVersion: 1},
Cache: config.CacheConfig{TTL: 60},
}); err != nil {
t.Fatalf("save: %v", err)
}
info, err := os.Stat(filepath.Join(dir, "netssh.yaml"))
if err != nil {
t.Fatalf("stat: %v", err)
}
if perm := info.Mode().Perm(); perm != 0o600 {
t.Errorf("file permissions: got %o, want 600", perm)
}
}
func TestSave_OmitsEmptyOptionalFields(t *testing.T) {
dir := t.TempDir()
orig := os.Getenv("XDG_CONFIG_HOME")
os.Setenv("XDG_CONFIG_HOME", dir)
defer os.Setenv("XDG_CONFIG_HOME", orig)
cfg := config.Config{
NetBox: config.NetBoxConfig{URL: "http://x", Token: "t", TokenVersion: 1},
Cache: config.CacheConfig{TTL: 60},
// No DefaultUser, no ManagementSubnets, no InterfaceName
}
if err := save(cfg); err != nil {
t.Fatalf("save: %v", err)
}
data, _ := os.ReadFile(filepath.Join(dir, "netssh.yaml"))
content := string(data)
for _, absent := range []string{"default_user", "management_subnets", "interface_name"} {
if strings.Contains(content, absent) {
t.Errorf("config should not contain %q when field is empty\nfull content:\n%s", absent, content)
}
}
}
func TestSave_CreatesConfigDir(t *testing.T) {
dir := filepath.Join(t.TempDir(), "does", "not", "exist")
orig := os.Getenv("XDG_CONFIG_HOME")
os.Setenv("XDG_CONFIG_HOME", dir)
defer os.Setenv("XDG_CONFIG_HOME", orig)
if err := save(config.Config{
NetBox: config.NetBoxConfig{URL: "http://x", Token: "t", TokenVersion: 1},
Cache: config.CacheConfig{TTL: 60},
}); err != nil {
t.Fatalf("save should create missing directories: %v", err)
}
}
func TestSave_DefaultPort(t *testing.T) {
dir := t.TempDir()
orig := os.Getenv("XDG_CONFIG_HOME")
os.Setenv("XDG_CONFIG_HOME", dir)
defer os.Setenv("XDG_CONFIG_HOME", orig)
cfg := config.Config{
NetBox: config.NetBoxConfig{URL: "http://x", Token: "t", TokenVersion: 1},
Cache: config.CacheConfig{TTL: 60},
SSH: config.SSHConfig{DefaultPort: 2222},
}
if err := save(cfg); err != nil {
t.Fatalf("save: %v", err)
}
data, _ := os.ReadFile(filepath.Join(dir, "netssh.yaml"))
if !strings.Contains(string(data), "default_port: 2222") {
t.Errorf("expected default_port: 2222 in config, got:\n%s", string(data))
}
}
func TestSave_Shortcuts_WritesSection(t *testing.T) {
dir := t.TempDir()
orig := os.Getenv("XDG_CONFIG_HOME")
os.Setenv("XDG_CONFIG_HOME", dir)
defer os.Setenv("XDG_CONFIG_HOME", orig)
cfg := config.Config{
NetBox: config.NetBoxConfig{URL: "http://x", Token: "t", TokenVersion: 1},
Cache: config.CacheConfig{TTL: 60},
Shortcuts: config.ShortcutsConfig{
Domains: []string{".example.com"},
StripHyphens: true,
},
}
if err := save(cfg); err != nil {
t.Fatalf("save: %v", err)
}
data, _ := os.ReadFile(filepath.Join(dir, "netssh.yaml"))
content := string(data)
for _, want := range []string{"shortcuts:", "domains:", ".example.com", "strip_hyphens: true"} {
if !strings.Contains(content, want) {
t.Errorf("expected %q in config, got:\n%s", want, content)
}
}
}
func TestSave_OmitsDefaultPortWhenZero(t *testing.T) {
dir := t.TempDir()
orig := os.Getenv("XDG_CONFIG_HOME")
os.Setenv("XDG_CONFIG_HOME", dir)
defer os.Setenv("XDG_CONFIG_HOME", orig)
cfg := config.Config{
NetBox: config.NetBoxConfig{URL: "http://x", Token: "t", TokenVersion: 1},
Cache: config.CacheConfig{TTL: 60},
SSH: config.SSHConfig{DefaultPort: 0},
}
if err := save(cfg); err != nil {
t.Fatalf("save: %v", err)
}
data, _ := os.ReadFile(filepath.Join(dir, "netssh.yaml"))
if strings.Contains(string(data), "default_port") {
t.Errorf("default_port should be omitted when zero, got:\n%s", string(data))
}
}
func TestSave_OmitsShortcutsWhenEmpty(t *testing.T) {
dir := t.TempDir()
orig := os.Getenv("XDG_CONFIG_HOME")
os.Setenv("XDG_CONFIG_HOME", dir)
defer os.Setenv("XDG_CONFIG_HOME", orig)
cfg := config.Config{
NetBox: config.NetBoxConfig{URL: "http://x", Token: "t", TokenVersion: 1},
Cache: config.CacheConfig{TTL: 60},
Shortcuts: config.ShortcutsConfig{}, // empty
}
if err := save(cfg); err != nil {
t.Fatalf("save: %v", err)
}
data, _ := os.ReadFile(filepath.Join(dir, "netssh.yaml"))
if strings.Contains(string(data), "shortcuts:") {
t.Errorf("shortcuts section should be omitted when empty, got:\n%s", string(data))
}
}
func TestSave_Roundtrip_WithNewFields(t *testing.T) {
dir := t.TempDir()
orig := os.Getenv("XDG_CONFIG_HOME")
os.Setenv("XDG_CONFIG_HOME", dir)
defer os.Setenv("XDG_CONFIG_HOME", orig)
original := config.Config{
NetBox: config.NetBoxConfig{
URL: "https://netbox.example.com",
Token: "nbt_test",
TokenVersion: 2,
},
SSH: config.SSHConfig{
DefaultUser: "admin",
DefaultPort: 2222,
},
Resolver: config.ResolverConfig{
Strategies: []string{"primary_ip"},
},
Cache: config.CacheConfig{TTL: 3600},
Shortcuts: config.ShortcutsConfig{
Domains: []string{".example.com", ".example.de"},
StripHyphens: true,
},
}
if err := save(original); err != nil {
t.Fatalf("save: %v", err)
}
loaded, err := config.Load()
if err != nil {
t.Fatalf("Load after save: %v", err)
}
if loaded.SSH.DefaultPort != original.SSH.DefaultPort {
t.Errorf("DefaultPort: got %d, want %d", loaded.SSH.DefaultPort, original.SSH.DefaultPort)
}
if len(loaded.Shortcuts.Domains) != len(original.Shortcuts.Domains) {
t.Errorf("Shortcuts.Domains length: got %d, want %d", len(loaded.Shortcuts.Domains), len(original.Shortcuts.Domains))
} else {
for i, d := range original.Shortcuts.Domains {
if loaded.Shortcuts.Domains[i] != d {
t.Errorf("Shortcuts.Domains[%d]: got %q, want %q", i, loaded.Shortcuts.Domains[i], d)
}
}
}
if loaded.Shortcuts.StripHyphens != original.Shortcuts.StripHyphens {
t.Errorf("StripHyphens: got %v, want %v", loaded.Shortcuts.StripHyphens, original.Shortcuts.StripHyphens)
}
}
func TestParseStrategies(t *testing.T) {
tests := []struct {
in string
want []string
}{
{"primary_ip", []string{"primary_ip"}},
{"management_subnet, primary_ip", []string{"management_subnet", "primary_ip"}},
{"primary_ip,management_subnet,interface_name", []string{"primary_ip", "management_subnet", "interface_name"}},
{" primary_ip , management_subnet ", []string{"primary_ip", "management_subnet"}},
{"", nil},
{" , ", nil},
}
for _, tt := range tests {
got := parseStrategies(tt.in)
if len(got) != len(tt.want) {
t.Errorf("parseStrategies(%q): got %v, want %v", tt.in, got, tt.want)
continue
}
for i := range got {
if got[i] != tt.want[i] {
t.Errorf("parseStrategies(%q)[%d]: got %q, want %q", tt.in, i, got[i], tt.want[i])
}
}
}
}
func TestParseStrategies_PreservesOrder(t *testing.T) {
got := parseStrategies("interface_name, management_subnet, primary_ip")
want := []string{"interface_name", "management_subnet", "primary_ip"}
for i, s := range got {
if s != want[i] {
t.Errorf("order not preserved at [%d]: got %q, want %q", i, s, want[i])
}
}
}
func TestValidateStrategiesHostname(t *testing.T) {
if err := validateStrategies("primary_ip, hostname"); err != nil {
t.Fatal(err)
}
}
func TestValidateStrategies_Valid(t *testing.T) {
cases := []string{
"primary_ip",
"management_subnet, primary_ip",
"interface_name, management_subnet, primary_ip",
}
for _, c := range cases {
if err := validateStrategies(c); err != nil {
t.Errorf("validateStrategies(%q) should be valid, got: %v", c, err)
}
}
}
func TestValidateStrategies_Invalid(t *testing.T) {
cases := []string{
"",
"unknown_strategy",
"primary_ip, typo",
}
for _, c := range cases {
if err := validateStrategies(c); err == nil {
t.Errorf("validateStrategies(%q) should return an error", c)
}
}
}
func TestSave_RoundtripViaLoad(t *testing.T) {
dir := t.TempDir()
orig := os.Getenv("XDG_CONFIG_HOME")
os.Setenv("XDG_CONFIG_HOME", dir)
defer os.Setenv("XDG_CONFIG_HOME", orig)
original := config.Config{
NetBox: config.NetBoxConfig{
URL: "https://netbox.zb-server.de",
Token: "nbt_supersecret",
TokenVersion: 2,
},
SSH: config.SSHConfig{DefaultUser: "root"},
Resolver: config.ResolverConfig{
Strategies: []string{"primary_ip"},
ManagementSubnets: []string{"192.168.0.0/16"},
InterfaceName: "eth0",
},
Cache: config.CacheConfig{TTL: 7200},
}
if err := save(original); err != nil {
t.Fatalf("save: %v", err)
}
loaded, err := config.Load()
if err != nil {
t.Fatalf("Load after save: %v", err)
}
if loaded.NetBox.URL != original.NetBox.URL {
t.Errorf("URL: got %q, want %q", loaded.NetBox.URL, original.NetBox.URL)
}
if loaded.NetBox.Token != original.NetBox.Token {
t.Errorf("Token: got %q, want %q", loaded.NetBox.Token, original.NetBox.Token)
}
if loaded.NetBox.TokenVersion != original.NetBox.TokenVersion {
t.Errorf("TokenVersion: got %d, want %d", loaded.NetBox.TokenVersion, original.NetBox.TokenVersion)
}
if loaded.SSH.DefaultUser != original.SSH.DefaultUser {
t.Errorf("DefaultUser: got %q, want %q", loaded.SSH.DefaultUser, original.SSH.DefaultUser)
}
if loaded.Cache.TTL != original.Cache.TTL {
t.Errorf("TTL: got %d, want %d", loaded.Cache.TTL, original.Cache.TTL)
}
}
func TestSaveCommandAliasRoundtripAndDisable(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
cfg := config.Config{Shell: config.ShellConfig{Alias: "j"}, Cache: config.CacheConfig{Path: filepath.Join(t.TempDir(), "hosts.json")}}
if err := save(cfg); err != nil {
t.Fatal(err)
}
loaded, err := config.Load()
if err != nil || loaded.Shell.Alias != "j" || loaded.Cache.Path != cfg.Cache.Path {
t.Fatalf("roundtrip: %+v, %v", loaded, err)
}
cfg.Shell.Alias = ""
if err := save(cfg); err != nil {
t.Fatal(err)
}
loaded, err = config.Load()
if err != nil || loaded.Shell.Alias != "" {
t.Fatalf("disable: %+v, %v", loaded, err)
}
}
func TestSaveMultipleNetBoxesRoundTrip(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
cfg := config.Config{
NetBoxes: []config.NetBoxConfig{
{Name: "office", URL: "https://office.example/netbox", Token: "nbt_secret: office", TokenVersion: 2},
{Name: "lab", URL: "https://lab.example", Token: "legacy#token", TokenVersion: 1},
},
Resolver: config.ResolverConfig{Strategies: []string{"primary_ip"}},
Cache: config.CacheConfig{TTL: 3600},
}
if err := save(cfg); err != nil {
t.Fatal(err)
}
loaded, err := config.Load()
if err != nil {
t.Fatal(err)
}
if len(loaded.NetBoxes) != 2 || loaded.NetBox.URL != "" {
t.Fatalf("instances lost during save: %+v", loaded)
}
for i, want := range cfg.NetBoxes {
if !reflect.DeepEqual(loaded.NetBoxes[i], want) {
t.Fatalf("instance changed: %+v, want %+v", loaded.NetBoxes[i], want)
}
}
info, err := os.Stat(config.Path())
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o600 {
t.Fatal("credentials are not private")
}
}
func TestSaveKeepsSSHOptions(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("HOME", t.TempDir())
opts := config.SSHOptions{
ProxyJump: "jh.example.com",
HostKeyChecking: "accept-new",
Target: "name",
AddressFamily: "inet",
ExtraArgs: []string{"-A"},
Options: map[string]string{"serveraliveinterval": "60"},
}
cfg := config.Config{
NetBoxes: []config.NetBoxConfig{{Name: "prod", URL: "https://prod.example", Token: "t", TokenVersion: 1, SSH: config.SSHOptions{HostKeyChecking: "off"}}},
Resolver: config.ResolverConfig{Strategies: []string{"primary_ip"}},
Cache: config.CacheConfig{TTL: 3600},
SSH: config.SSHConfig{
DefaultUser: "root",
SSHOptions: opts,
Rules: []config.SSHRule{{Match: config.SSHMatch{Tag: "dmz", Name: "*.dmz"}, SSHOptions: config.SSHOptions{ProxyJump: "none"}}},
},
Completion: config.CompletionConfig{MaxResults: 100},
}
if err := save(cfg); err != nil {
t.Fatal(err)
}
loaded, err := config.Load()
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(loaded.SSH, cfg.SSH) || !reflect.DeepEqual(loaded.NetBoxes[0].SSH, cfg.NetBoxes[0].SSH) || loaded.Completion != cfg.Completion {
t.Fatalf("ssh settings lost:\n%+v\n%+v", loaded.SSH, loaded.NetBoxes[0].SSH)
}
}
// Re-running the configurator must keep settings that have no form field of
// their own or were not touched in the menu.
func TestPrepareKeepsUnmanagedSettings(t *testing.T) {
cfg := config.Config{
NetBox: config.NetBoxConfig{URL: "https://legacy.example", Token: "t"},
SSH: config.SSHConfig{
DefaultUser: "root",
SSHOptions: config.SSHOptions{ProxyJump: "jh.example.com", Options: map[string]string{"forwardagent": "yes"}},
Rules: []config.SSHRule{{Match: config.SSHMatch{Tag: "dmz"}, SSHOptions: config.SSHOptions{ProxyJump: "none"}}},
},
Completion: config.CompletionConfig{MaxResults: 50},
Cache: config.CacheConfig{Path: "/var/cache/netssh/hosts.json"},
}
sources := []config.NetBoxConfig{{Name: "default", URL: "https://legacy.example", Token: "t"}}
got := prepare(cfg, sources)
if !reflect.DeepEqual(got.SSH, cfg.SSH) || got.Completion != cfg.Completion || got.Cache.Path != cfg.Cache.Path {
t.Fatalf("settings lost: %+v", got)
}
if got.NetBox.URL != "" || !reflect.DeepEqual(got.NetBoxes, sources) {
t.Fatalf("legacy instance not migrated: %+v / %+v", got.NetBox, got.NetBoxes)
}
if !reflect.DeepEqual(got.Resolver.Strategies, []string{"primary_ip"}) || got.Cache.TTL != 3600 {
t.Fatalf("defaults missing: %+v %+v", got.Resolver, got.Cache)
}
}
func TestSaveRejectsInvalidSSHOptions(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("HOME", t.TempDir())
cfg := config.Config{
NetBoxes: []config.NetBoxConfig{{Name: "prod", URL: "https://prod.example", Token: "t"}},
Resolver: config.ResolverConfig{Strategies: []string{"primary_ip"}},
SSH: config.SSHConfig{Rules: []config.SSHRule{{SSHOptions: config.SSHOptions{ProxyJump: "none"}}}},
}
if err := save(cfg); err == nil {
t.Fatal("rule without match was saved")
}
if _, err := os.Stat(config.Path()); !os.IsNotExist(err) {
t.Fatalf("config file written despite error: %v", err)
}
}
func TestParseRewrites(t *testing.T) {
text := `^([a-z]+)[0-9]+- => $1
\.lab$ =>
`
got, err := parseRewrites(text)
if err != nil {
t.Fatal(err)
}
want := []config.ShortcutRewrite{{Pattern: `^([a-z]+)[0-9]+-`, Replace: "$1"}, {Pattern: `\.lab$`, Replace: ""}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %+v", got)
}
if again, _ := parseRewrites(formatRewrites(got)); !reflect.DeepEqual(again, want) {
t.Fatalf("roundtrip: %+v", again)
}
for _, bad := range []string{"no arrow", "[a- => x", " => x"} {
if _, err := parseRewrites(bad); err == nil {
t.Errorf("%q accepted", bad)
}
}
}
func TestSaveShortcutRewritesRoundtrip(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("HOME", t.TempDir())
rewrites := []config.ShortcutRewrite{{Pattern: `^([a-z]+)[0-9]+-`, Replace: "$1"}, {Pattern: `\.lab\.example\.com$`, Replace: `"q"`}}
cfg := config.Config{
NetBoxes: []config.NetBoxConfig{{Name: "prod", URL: "https://prod.example", Token: "t"}},
Resolver: config.ResolverConfig{Strategies: []string{"primary_ip"}},
Shortcuts: config.ShortcutsConfig{Rewrites: rewrites},
}
if err := save(cfg); err != nil {
t.Fatal(err)
}
loaded, err := config.Load()
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(loaded.Shortcuts.Rewrites, rewrites) {
t.Fatalf("got %+v", loaded.Shortcuts.Rewrites)
}
}
func TestSaveForwardsRoundtrip(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("HOME", t.TempDir())
global := map[string]config.Forward{"web": {Local: "8080:localhost:80"}, "socks": {Dynamic: "1080"}}
cfg := config.Config{
NetBoxes: []config.NetBoxConfig{{Name: "prod", URL: "https://prod.example", Token: "t", SSH: config.SSHOptions{Forwards: map[string]config.Forward{"db": {Local: "5432:localhost:5432"}}}}},
Resolver: config.ResolverConfig{Strategies: []string{"primary_ip"}},
SSH: config.SSHConfig{
SSHOptions: config.SSHOptions{Forwards: global},
Rules: []config.SSHRule{{Match: config.SSHMatch{Tag: "dmz"}, SSHOptions: config.SSHOptions{Forwards: map[string]config.Forward{"back": {Remote: "9000:localhost:9000"}}}}},
},
}
if err := save(cfg); err != nil {
t.Fatal(err)
}
loaded, err := config.Load()
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(loaded.SSH.Forwards, global) ||
!reflect.DeepEqual(loaded.NetBoxes[0].SSH.Forwards, cfg.NetBoxes[0].SSH.Forwards) ||
!reflect.DeepEqual(loaded.SSH.Rules[0].Forwards, cfg.SSH.Rules[0].Forwards) {
t.Fatalf("forwards lost: %+v / %+v / %+v", loaded.SSH.Forwards, loaded.NetBoxes[0].SSH.Forwards, loaded.SSH.Rules)
}
}
func TestSaveStaticHostsRoundtrip(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("HOME", t.TempDir())
hosts := []config.StaticHost{
{Name: "router", Address: "192.168.1.1", User: "admin", Port: 2222, Tags: []string{"home"}, SSH: config.SSHOptions{ProxyJump: "none"}},
{Name: "nas", Address: "nas.example.org"},
}
cfg := config.Config{
Resolver: config.ResolverConfig{Strategies: []string{"primary_ip", "hostname"}, HostnameDomain: "example.com"},
Hosts: hosts,
}
if err := save(cfg); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(config.Path())
if strings.Contains(string(data), "netbox:") {
t.Fatalf("static-only config got an empty netbox section:\n%s", data)
}
loaded, err := config.Load()
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(loaded.Hosts, hosts) || loaded.Resolver.HostnameDomain != "example.com" || loaded.HasNetBox() {
t.Fatalf("got %+v / %+v", loaded.Hosts, loaded.Resolver)
}
}
func TestValidateHostIn(t *testing.T) {
hosts := []config.StaticHost{{Name: "router", Address: "10.0.0.1"}, {Name: "nas", Address: "10.0.0.2"}}
if err := validateHostIn(config.StaticHost{Name: "Router", Address: "10.0.0.9"}, hosts, -1); err == nil {
t.Fatal("duplicate name accepted")
}
if err := validateHostIn(config.StaticHost{Name: "router", Address: "10.0.0.9"}, hosts, 0); err != nil {
t.Fatalf("editing a host in place rejected: %v", err)
}
if err := validateHostIn(config.StaticHost{Name: "new"}, hosts, -1); err != nil {
t.Fatalf("empty address while typing rejected: %v", err)
}
if err := validateHostIn(config.StaticHost{Name: "new", Address: "a b"}, hosts, -1); err == nil {
t.Fatal("invalid address accepted")
}
}