netssh multi opens one pane per host in a tmux window or zellij tab. Hosts come from names, tag:TAG (NetBox and static hosts), cluster:NAME (NetBox virtualization clusters; devices by default, --kind vm|all), and groups from the config (@name, nested, with cycle detection). Every pane runs netssh HOST, so lookup, rules, and jump hosts apply. - tmux: a new window inside tmux, else a new session that is attached; tiled layout, pane borders with the host and a [SYNC] marker. - zellij: a generated grid layout; a new tab inside zellij, else a new session. - --sync broadcasts typing to all panes; netssh sync toggles it in the current tmux window or zellij tab. - TUI: Space marks hosts, Ctrl+A marks all, Enter opens them together. - Configurator: multiplexer, panes per window, groups. NetBox SearchAll gains a cluster filter (name to cluster IDs per instance), and cache entries keep the host's cluster for completion and an offline fallback. Panes skip the 24-hour background refresh (checked once by multi) and get HOME/XDG paths but no NETSSH_* values, which would expose tokens in the process list. testenv/tests/15-multi.sh checks real tmux and zellij sessions, including a broadcast command arriving on all three cluster nodes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
654 lines
21 KiB
Go
654 lines
21 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")
|
|
}
|
|
}
|
|
|
|
func TestParseGroupsAndSaveMulti(t *testing.T) {
|
|
groups, err := parseGroups("K8s: master1, tag:k8s-worker\npve: cluster:pve-fsn1 @k8s\n")
|
|
want := map[string][]string{"k8s": {"master1", "tag:k8s-worker"}, "pve": {"cluster:pve-fsn1", "@k8s"}}
|
|
if err != nil || !reflect.DeepEqual(groups, want) {
|
|
t.Fatalf("got %v %v", groups, err)
|
|
}
|
|
if again, _ := parseGroups(formatGroups(groups)); !reflect.DeepEqual(again, want) {
|
|
t.Fatalf("roundtrip: %v", again)
|
|
}
|
|
for _, bad := range []string{"no colon", "tag:x", "bad name: a", "empty:"} {
|
|
if _, err := parseGroups(bad); err == nil {
|
|
t.Errorf("%q accepted", bad)
|
|
}
|
|
}
|
|
|
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
|
t.Setenv("HOME", t.TempDir())
|
|
cfg := config.Config{
|
|
Hosts: []config.StaticHost{{Name: "a", Address: "10.0.0.1"}},
|
|
Groups: groups,
|
|
Multi: config.MultiConfig{Multiplexer: "zellij", PanesPerWindow: 4},
|
|
}
|
|
if err := save(cfg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
loaded, err := config.Load()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !reflect.DeepEqual(loaded.Groups, want) || loaded.Multi != cfg.Multi {
|
|
t.Fatalf("got %v %+v", loaded.Groups, loaded.Multi)
|
|
}
|
|
}
|