Files
ssh-netbox-wrapper/internal/config/ssh_test.go
T
Sebastian UnterschützandClaude Opus 5.5 396d489187 feat(multi): tmux keys to toggle broadcast and exclude single panes
In windows opened by netssh multi, prefix S toggles broadcast for all
panes and prefix e excludes the current pane (a pane-level
synchronize-panes off, shown as [SOLO] in the border) or includes it
again. netssh sync --pane does the same from a shell.

S and e are unbound in a default tmux (E would be "spread evenly").
netssh writes the bindings to a small tmux config and sources it, binds
a key only if the user's tmux.conf does not use it (netssh's own earlier
binding is refreshed), and the bindings act only in windows marked
@netssh. multi.sync_key and multi.solo_key change the keys or turn them
off (none); also in the configurator. zellij syncs whole tabs only, so
excluding a pane is tmux-only.

testenv: the render test presses the keys through the xterm client
(send-keys -K) and checks [SOLO], that broadcast skips the excluded
pane, that S toggles, and that other windows are unaffected. The client
gets GNU grep: BusyBox grep -oF counted only one match per line.

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

213 lines
6.4 KiB
Go

package config
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
func loadYAML(t *testing.T, data string) (*Config, error) {
t.Helper()
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
t.Setenv("HOME", t.TempDir())
if err := os.WriteFile(filepath.Join(dir, "netssh.yaml"), []byte(data), 0o600); err != nil {
t.Fatal(err)
}
return Load()
}
func TestSSHOptionsLayering(t *testing.T) {
cfg, err := loadYAML(t, `
netboxes:
- name: prod
url: https://prod.example.com
ssh:
proxy_jump: jh-prod.example.com
host_key_checking: strict
- name: staging
url: https://staging.example.com
ssh:
host_key_checking: off
ssh:
proxy_jump: jh.example.com
extra_args: ["-4"]
options:
ServerAliveInterval: 60
ServerAliveCountMax: 5
rules:
- match: {tag: dmz}
proxy_jump: jh-dmz.example.com
options: {ServerAliveInterval: 10}
- match: {instance: prod, name: "db*"}
proxy_jump: none
completion:
max_results: 200
`)
if err != nil {
t.Fatal(err)
}
if cfg.Completion.MaxResults != 200 {
t.Fatalf("max_results: %d", cfg.Completion.MaxResults)
}
for _, tc := range []struct {
host SSHHost
jump, keys, alive string
}{
{SSHHost{Name: "web"}, "jh.example.com", "", "60"},
{SSHHost{Instance: "prod", Name: "web"}, "jh-prod.example.com", "strict", "60"},
{SSHHost{Instance: "staging", Name: "web", Tags: []string{"DMZ"}}, "jh-dmz.example.com", "off", "10"},
{SSHHost{Instance: "prod", Name: "db01", Tags: []string{"dmz"}}, "none", "strict", "10"},
} {
got := cfg.SSHOptionsFor(tc.host)
if got.ProxyJump != tc.jump || got.HostKeyChecking != tc.keys || got.Options["serveraliveinterval"] != tc.alive || got.Options["serveralivecountmax"] != "5" {
t.Errorf("%+v: got %+v", tc.host, got)
}
if !reflect.DeepEqual(got.ExtraArgs, []string{"-4"}) {
t.Errorf("extra args: %v", got.ExtraArgs)
}
}
}
func TestSSHOptionsValidation(t *testing.T) {
for _, data := range []string{
"ssh:\n host_key_checking: maybe\n",
"ssh:\n target: dns\n",
"ssh:\n address_family: ipx\n",
"ssh:\n proxy_jump: \"-oProxyCommand=x\"\n",
"ssh:\n options:\n \"bad key\": 1\n",
"ssh:\n rules:\n - proxy_jump: jh\n",
"netboxes:\n - name: a\n url: https://a.example\n ssh:\n target: nope\n",
} {
if _, err := loadYAML(t, data); err == nil {
t.Errorf("accepted invalid config:\n%s", data)
} else if !strings.Contains(err.Error(), "ssh") {
t.Errorf("unclear error %v", err)
}
}
}
func TestForwardsLayering(t *testing.T) {
cfg, err := loadYAML(t, `
netboxes:
- name: prod
url: https://prod.example
token: t
ssh:
forwards:
db: {local: "5432:localhost:5432"}
ssh:
forwards:
web: {local: "8080:localhost:80"}
socks: {dynamic: "1080"}
rules:
- match: {tag: dmz}
forwards:
web: {local: "8443:localhost:443"}
`)
if err != nil {
t.Fatal(err)
}
got := cfg.SSHOptionsFor(SSHHost{Instance: "prod", Name: "web01", Tags: []string{"dmz"}}).Forwards
want := map[string]Forward{"web": {Local: "8443:localhost:443"}, "socks": {Dynamic: "1080"}, "db": {Local: "5432:localhost:5432"}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %+v", got)
}
if got := cfg.SSHOptionsFor(SSHHost{Instance: "other", Name: "web01"}).Forwards; len(got) != 2 || got["web"].Local != "8080:localhost:80" {
t.Fatalf("other instance: %+v", got)
}
if f := (Forward{Remote: "9000:localhost:9000"}); f.String() != "-R 9000:localhost:9000" {
t.Fatal(f.String())
}
}
func TestForwardsValidation(t *testing.T) {
for _, forwards := range []string{
`web: {local: "8080:a:80", dynamic: "1080"}`,
`web: {local: "-oProxyCommand=x"}`,
`web: {local: "8080 a:80"}`,
`"bad name": {local: "8080:a:80"}`,
} {
if _, err := loadYAML(t, "ssh:\n forwards:\n "+forwards+"\n"); err == nil || !strings.Contains(err.Error(), "forwards") {
t.Errorf("%s: got %v", forwards, err)
}
}
}
func TestStaticHosts(t *testing.T) {
cfg, err := loadYAML(t, `
resolver:
hostname_domain: example.com
ssh:
proxy_jump: jh
rules:
- match: {kind: static, tag: home}
host_key_checking: off
hosts:
- name: Router
address: 192.168.1.1
user: admin
port: 2222
tags: [home]
ssh:
proxy_jump: none
`)
if err != nil {
t.Fatal(err)
}
if !cfg.HasHosts() || cfg.HasNetBox() || cfg.Resolver.HostnameDomain != "example.com" {
t.Fatalf("got %+v", cfg)
}
host := cfg.StaticHost("router")
if host == nil || host.Address != "192.168.1.1" || host.User != "admin" || host.Port != 2222 {
t.Fatalf("host: %+v", host)
}
opts := cfg.SSHOptionsFor(SSHHost{Name: "Router", Kind: StaticKind, Tags: []string{"home"}})
if opts.ProxyJump != "none" || opts.HostKeyChecking != "off" {
t.Fatalf("static host options: %+v", opts)
}
// A NetBox host of the same name does not get the static host's options.
if got := cfg.SSHOptionsFor(SSHHost{Name: "Router", Kind: "device"}); got.ProxyJump != "jh" {
t.Fatalf("netbox host: %+v", got)
}
}
func TestStaticHostsValidation(t *testing.T) {
for _, hosts := range []string{
"- {name: a, address: 10.0.0.1}\n - {name: A, address: 10.0.0.2}",
"- {name: 'bad name', address: 10.0.0.1}",
"- {name: prod/a, address: 10.0.0.1}",
"- {name: a}",
"- {name: a, address: '-oProxyCommand=x'}",
"- {name: a, address: 10.0.0.1, port: 70000}",
"- {name: a, address: 10.0.0.1, user: 'a b'}",
"- {name: a, address: 10.0.0.1, ssh: {target: nope}}",
} {
if _, err := loadYAML(t, "hosts:\n "+hosts+"\n"); err == nil || !strings.Contains(err.Error(), "hosts[") {
t.Errorf("%s: got %v", hosts, err)
}
}
}
func TestMultiConfig(t *testing.T) {
cfg, err := loadYAML(t, "groups:\n K8s: [master1, \"tag:worker\"]\nmulti:\n multiplexer: zellij\n")
if err != nil || len(cfg.Groups["k8s"]) != 2 || cfg.Multi.Multiplexer != "zellij" || cfg.Multi.PanesPerWindowOrDefault() != 9 {
t.Fatalf("%+v %v", cfg, err)
}
if sync, solo := cfg.Multi.Keys(); sync != "S" || solo != "e" {
t.Fatalf("default keys %q %q", sync, solo)
}
custom, err := loadYAML(t, "multi: {sync_key: C-s, solo_key: none}\n")
if sync, solo := custom.Multi.Keys(); err != nil || sync != "C-s" || solo != "" {
t.Fatalf("custom keys %q %q %v", sync, solo, err)
}
for _, bad := range []string{"multi: {multiplexer: screen}", "multi: {panes_per_window: 100}", "groups: {web: []}", "groups: {web: [\"a b\"]}",
"multi: {sync_key: \"x y\"}", "multi: {sync_key: \"{\"}", "multi: {sync_key: e}"} {
if _, err := loadYAML(t, bad+"\n"); err == nil {
t.Errorf("%s accepted", bad)
}
}
}