Files
ssh-netbox-wrapper/internal/ssh/client_test.go
T
Sebastian UnterschützandClaude Opus 5.5 b7fa71fe45 feat: sftp, scp, and named port forwards
Add netssh sftp and netssh scp. Both resolve the host like a connection
does (cache, shortcuts and rewrites, instance/host, default user and
port, SSH options and rules, --jump/--no-jump, --dry-run) and get shell
completion. The per-client flag tables handle the differences from ssh:
the port is -P, -l is a bandwidth limit (the default user goes in as
-o User=), and IPv6 addresses are bracketed in host:path. All remote
scp operands must name the same host.

Add named port forwards under ssh.forwards (global, per instance, per
rule; a more specific layer replaces a forward of the same name),
netssh tunnel HOST NAME|SPEC... (ssh -N with ExitOnForwardFailure) and
netssh --forward NAME. Specs such as 8080:localhost:80 work without
configuration. Forwards are editable in netssh configure and completed
in the shell; the TUI opens SFTP with ctrl+o and a tunnel with ctrl+t.

Host resolution moves out of runSSHWrapper into resolveTarget, shared
by ssh, sftp, scp, tunnel, and the TUI.

Options added by netssh are now inserted before the first operand or
"--": after "--", ssh read them as the destination, and scp stops
reading options at the first operand.

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

157 lines
6.9 KiB
Go

package ssh
import (
"reflect"
"strings"
"testing"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
)
func TestSFTPParse(t *testing.T) {
// -l is a bandwidth limit and -s takes a value for sftp, unlike ssh.
parsed := SFTP.Parse([]string{"-l", "100", "-s", "sub", "-P", "2222", "ops@web01:/var/log"})
if parsed == nil || parsed.Host != "web01" || parsed.User != "ops" || parsed.Path != "/var/log" || parsed.DestIdx != 6 {
t.Fatalf("got %+v", parsed)
}
if p := SFTP.Parse([]string{"prod/web01"}); p == nil || p.Host != "prod/web01" || p.Path != "" {
t.Fatalf("instance-qualified host: %+v", p)
}
if !SFTP.HasPortFlag([]string{"-P", "2222", "web"}) || SFTP.HasPortFlag([]string{"-p", "web"}) {
t.Fatal("sftp port flag is -P")
}
if SFTP.HasUserFlag([]string{"-l", "100", "web"}) || !SFTP.HasUserFlag([]string{"-o", "User=ops", "web"}) {
t.Fatal("sftp has no -l user flag")
}
}
func TestSCPOperands(t *testing.T) {
ops, err := SCP.Operands([]string{"-X", "nrequests=8", "-r", "./local:dir", "/abs/file", "ops@web01:/tmp/", "web01:b"})
if err != nil {
t.Fatal(err)
}
want := []Operand{{Idx: 5, User: "ops", Host: "web01", Path: "/tmp/"}, {Idx: 6, Host: "web01", Path: "b"}}
if !reflect.DeepEqual(ops, want) {
t.Fatalf("got %+v", ops)
}
if ops, _ := SCP.Operands([]string{"a.txt", "b.txt"}); len(ops) != 0 {
t.Fatalf("local copy has remote operands: %+v", ops)
}
if _, err := SCP.Operands([]string{"web01:a", "db01:b"}); err == nil {
t.Fatal("two remote hosts accepted")
}
if _, err := SCP.Operands([]string{"scp://web01/a", "."}); err == nil {
t.Fatal("URI accepted")
}
}
func TestSFTPApply(t *testing.T) {
args := []string{"-P", "2222", "ops@web01:/var/log"}
opts := config.SSHOptions{ProxyJump: "jh", ExtraArgs: []string{"-A"}, Forwards: map[string]config.Forward{"web": {Local: "1:a:2"}}}
got := SFTP.Apply(args, []int{2}, Host{Name: "web01", IP: "192.0.2.10"}, opts, WrapperFlags{ForwardArgs: []string{"-L", "1:a:2"}}).Args
want := []string{"-P", "2222", "-J", "jh", "ops@192.0.2.10:/var/log"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v (extra_args and forwards are ssh only)", got, want)
}
}
func TestSCPApplyReplacesEveryOperandAndBracketsIPv6(t *testing.T) {
args := []string{"a.txt", "web01:/tmp/", "ops@web01:b"}
got := SCP.Apply(args, []int{1, 2}, Host{Name: "web01", IP: "2001:db8::1"}, config.SSHOptions{HostKeyChecking: "accept-new"}, WrapperFlags{}).Args
want := []string{"-o", "StrictHostKeyChecking=accept-new", "a.txt", "[2001:db8::1]:/tmp/", "ops@[2001:db8::1]:b"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v", got)
}
// sftp needs brackets even without a path; ssh never does.
if got := SFTP.Apply([]string{"web01"}, []int{0}, Host{Name: "web01", IP: "2001:db8::1"}, config.SSHOptions{}, WrapperFlags{}).Args; got[0] != "[2001:db8::1]" {
t.Fatalf("sftp: %v", got)
}
if got := apply([]string{"web01"}, config.SSHOptions{}, WrapperFlags{}); got.Args[0] != "192.0.2.10" {
t.Fatalf("ssh: %v", got.Args)
}
}
func TestSFTPTargetName(t *testing.T) {
got := SFTP.Apply([]string{"web01:/srv"}, []int{0}, Host{Name: "web01.example.com", IP: "192.0.2.10"}, config.SSHOptions{Target: "name"}, WrapperFlags{}).Args
want := []string{"-o", "HostName=192.0.2.10", "-o", "HostKeyAlias=web01.example.com", "web01.example.com:/srv"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v", got)
}
}
func TestClientUserAndPortArgs(t *testing.T) {
if got := SSH.UserArgs("ops"); !reflect.DeepEqual(got, []string{"-l", "ops"}) {
t.Fatal(got)
}
if got := SCP.UserArgs("ops"); !reflect.DeepEqual(got, []string{"-o", "User=ops"}) {
t.Fatal(got)
}
if got := SFTP.PortArgs("2222"); !reflect.DeepEqual(got, []string{"-P", "2222"}) {
t.Fatal(got)
}
}
func TestClientExtractWrapperFlags(t *testing.T) {
// For scp, -X takes a value; for ssh it does not.
flags, rest, err := SCP.ExtractWrapperFlags([]string{"-X", "--dry-run", "--dry-run", "a", "web:b"})
if err != nil || !flags.DryRun || !reflect.DeepEqual(rest, []string{"-X", "--dry-run", "a", "web:b"}) {
t.Fatalf("got %+v %v %v", flags, rest, err)
}
flags, rest, err = ExtractWrapperFlags([]string{"--forward", "web", "--forward=8080:localhost:80", "-v", "web01"})
if err != nil || !reflect.DeepEqual(flags.Forwards, []string{"web", "8080:localhost:80"}) || !reflect.DeepEqual(rest, []string{"-v", "web01"}) {
t.Fatalf("got %+v %v %v", flags, rest, err)
}
for _, args := range [][]string{{"--forward"}, {"--forward", "-L", "web"}} {
if _, _, err := ExtractWrapperFlags(args); err == nil {
t.Errorf("%v accepted", args)
}
}
}
func TestForwardArgs(t *testing.T) {
available := map[string]config.Forward{"web": {Local: "8080:localhost:80"}, "socks": {Dynamic: "1080"}, "back": {Remote: "9000:localhost:9000"}}
got, err := ForwardArgs([]string{"WEB", "socks", "back", "5432:db:5432", "127.0.0.1:3000:localhost:3000"}, available)
want := []string{"-L", "8080:localhost:80", "-D", "1080", "-R", "9000:localhost:9000", "-L", "5432:db:5432", "-L", "127.0.0.1:3000:localhost:3000"}
if err != nil || !reflect.DeepEqual(got, want) {
t.Fatalf("got %v %v", got, err)
}
if _, err := ForwardArgs([]string{"db"}, available); err == nil || !strings.Contains(err.Error(), "back, socks, web") {
t.Fatalf("unknown name: %v", err)
}
if _, err := ForwardArgs([]string{"db"}, nil); err == nil || !strings.Contains(err.Error(), "no forwards are configured") {
t.Fatalf("no forwards: %v", err)
}
}
func TestApplyForwardsAndTunnel(t *testing.T) {
flags := WrapperFlags{ForwardArgs: []string{"-L", "8080:localhost:80"}}
if got := apply([]string{"web"}, config.SSHOptions{}, flags).Args; !reflect.DeepEqual(got, []string{"-L", "8080:localhost:80", "192.0.2.10"}) {
t.Fatalf("forward: %v", got)
}
flags.Tunnel = true
result := apply([]string{"web"}, config.SSHOptions{}, flags)
want := []string{"-L", "8080:localhost:80", "-o", "ExitOnForwardFailure=yes", "-N", "192.0.2.10"}
if !reflect.DeepEqual(result.Args, want) || !strings.Contains(result.Notice, "forwarding -L 8080:localhost:80 via web.example.com") {
t.Fatalf("tunnel: %v %q", result.Args, result.Notice)
}
// The user's own ExitOnForwardFailure wins.
if got := apply([]string{"-o", "ExitOnForwardFailure=no", "web"}, config.SSHOptions{}, flags).Args; strings.Contains(strings.Join(got, " "), "=yes") {
t.Fatalf("overrode user option: %v", got)
}
}
func TestHasForwardFlag(t *testing.T) {
for args, want := range map[string]bool{"-L 1:a:2 web": true, "-vD1080 web": true, "-o RemoteForward=1_a_2 web": true, "-l ops web": false, "web -L 1:a:2": false} {
if got := SSH.HasForwardFlag(strings.Fields(args)); got != want {
t.Errorf("%q: got %v", args, got)
}
}
}
func TestApplyInsertsOptionsBeforeDoubleDash(t *testing.T) {
got := Apply([]string{"-v", "--", "web"}, 2, Host{Name: "web", IP: "192.0.2.10"}, config.SSHOptions{ProxyJump: "jh"}, WrapperFlags{}).Args
if want := []string{"-v", "-J", "jh", "--", "192.0.2.10"}; !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}