spiegel_podman/pkg/domain/utils/scp_test.go
Scott Callaway 011b659569
pkg/domain/utils: tidy SaveToRemote's remote commands
Three small things in one place, all groundwork rather than behaviour:

The path SaveToRemote gets back from the remote mktemp still carries the
trailing newline ssh.Exec hands over with the rest of the raw output. That is
harmless while the path is only ever the last thing on a command line, but it
is a trap for anything that appends to it. Trim it.

The host, identity, port and user were restated in full for every command.
State them once and let each command copy the value and add its own argv.

Removing a file on the far end had one caller and was about to have more, so
give it a name. It also gains -f, since a caller cleaning up after a failure
cannot know which of the paths it is removing were created.

Lastly, the ssh operations a transfer performs are gathered into one value the
exported entry points pass in. Nothing about the options changes: SaveToRemote
and LoadToRemote keep taking their options struct, and the body moves to an
unexported function taking that same struct plus the operations to run it with.
That is what lets a test assert the sequence of remote commands, and what is
streamed to them, without a host to run against.

Signed-off-by: Scott Callaway <github@scottcallaway.co.uk>
2026-09-07 15:59:27 +01:00

211 lines
4.9 KiB
Go

package utils
import (
"fmt"
"io"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"go.podman.io/common/pkg/ssh"
"go.podman.io/podman/v6/pkg/domain/entities"
)
func TestValidateSCPArgs(t *testing.T) {
type args struct {
locations []*entities.ScpTransferImageOptions
}
tests := []struct {
name string
args args
wantErr assert.ErrorAssertionFunc
}{
{
name: "test args length more than 2",
args: args{
locations: []*entities.ScpTransferImageOptions{
{
Image: "source image one",
},
{
Image: "source image two",
},
{
Image: "target image one",
},
{
Image: "target image two",
},
},
},
wantErr: assert.Error,
},
{
name: "test source image is empty",
args: args{
locations: []*entities.ScpTransferImageOptions{
{
Image: "",
},
{
Image: "target image",
},
},
},
wantErr: assert.NoError,
},
{
name: "test target image is empty",
args: args{
locations: []*entities.ScpTransferImageOptions{
{
Image: "source image",
},
{
Image: "target image",
},
},
},
wantErr: assert.NoError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.wantErr(t, ValidateSCPArgs(tt.args.locations), fmt.Sprintf("ValidateSCPArgs(%v)", tt.args.locations))
})
}
}
// The trailing newline is harmless while the path is last on a command line, but
// anything appending to it splices the newline into the middle.
func TestTrimRemotePath(t *testing.T) {
tests := []struct {
name string
out string
want string
}{
{
name: "path as mktemp prints it",
out: "/tmp/tmp.5CGFmzWnCu\n",
want: "/tmp/tmp.5CGFmzWnCu",
},
{
name: "path with a carriage return",
out: "/tmp/tmp.5CGFmzWnCu\r\n",
want: "/tmp/tmp.5CGFmzWnCu",
},
{
name: "path already trimmed",
out: "/tmp/tmp.5CGFmzWnCu",
want: "/tmp/tmp.5CGFmzWnCu",
},
{
name: "empty output",
out: "",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := trimRemotePath(tt.out)
assert.Equal(t, tt.want, got)
// Appending has to stay on one line: this is what the trim is for.
assert.NotContains(t, got+".gz", "\n")
})
}
}
func TestParseImageSCPArg(t *testing.T) {
tests := []struct {
name string
arg string
wantUser string
}{
{
name: "user without domain",
arg: "user@localhost::alpine",
wantUser: "user",
},
{
name: "username containing an @ (e.g. Active Directory)",
arg: "user@domain@localhost::example.com/foo/bar:latest",
wantUser: "user@domain",
},
{
name: "no username before @localhost::",
arg: "@localhost::alpine",
wantUser: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
location, _, err := ParseImageSCPArg(tt.arg)
assert.NoError(t, err)
assert.Equal(t, tt.wantUser, location.User)
})
}
}
// fakeRemote records the commands that would have run over ssh and replays a
// canned error for each.
type fakeRemote struct {
// errs is consulted per call, by index; nil succeeds.
errs []error
// out is what a command prints, by index, for the calls whose output the
// transfer reads back.
out []string
argv [][]string
// input is everything streamed to the command that took a stream.
input []byte
// scpOpts records each copy that was asked for, and scpErr fails it.
scpOpts []ssh.ConnectionScpOptions
scpErr error
}
func (f *fakeRemote) exec(opts *ssh.ConnectionExecOptions, _ ssh.EngineMode) (string, error) {
return f.record(opts.Args)
}
func (f *fakeRemote) execWithInput(opts *ssh.ConnectionExecOptions, _ ssh.EngineMode, input io.Reader) (string, error) {
var err error
if f.input, err = io.ReadAll(input); err != nil {
return "", err
}
return f.record(opts.Args)
}
// scp mirrors ssh.Scp, which reports back the remote path it copied. Returning
// it is what lets a test see which path the cleanup is given.
func (f *fakeRemote) scp(opts *ssh.ConnectionScpOptions, _ ssh.EngineMode) (string, error) {
f.scpOpts = append(f.scpOpts, *opts)
if f.scpErr != nil {
return "", f.scpErr
}
// The source is an ssh:// URL with the path after the last colon.
return opts.Source[strings.LastIndex(opts.Source, ":")+1:], nil
}
func (f *fakeRemote) record(argv []string) (string, error) {
f.argv = append(f.argv, argv)
i := len(f.argv) - 1
var err error
if i < len(f.errs) {
err = f.errs[i]
}
if i < len(f.out) {
return f.out[i], err
}
return "", err
}
func (f *fakeRemote) runner() remoteRunner {
return remoteRunner{exec: f.exec, execWithInput: f.execWithInput, scp: f.scp}
}
// -f matters: the cleanup has to tolerate a path that was never created.
func TestRemoveRemoteFiles(t *testing.T) {
remote := &fakeRemote{}
removeRemoteFiles(remote.exec, ssh.ConnectionExecOptions{}, ssh.GolangMode, "/tmp/a", "/tmp/b")
assert.Equal(t, [][]string{{"rm", "-f", "/tmp/a", "/tmp/b"}}, remote.argv)
}