package cli_test import ( "os" "path/filepath" "strconv" "strings" "testing" "git.eeqj.de/sneak/keyfunc/internal/cli" "git.eeqj.de/sneak/keyfunc/internal/cli/ssh" "git.eeqj.de/sneak/keyfunc/internal/mnemonic" "github.com/stretchr/testify/require" ) // The modes the host is supposed to end up with, and the mode the // stand-in ssh needs so that it can be run at all. const ( directoryMode = 0o700 fileMode = 0o600 standInMode = 0o755 ) // failingStatus is the status the stand-in ssh ends with when a test // wants to see a status handed on. const failingStatus = 7 // The host, and where on it the key ends up. const ( host = "someone@example.com" keptUnder = ".ssh" keptIn = "authorized_keys" ) // installer is a stand-in for the system ssh for the install command. // It writes down what it was given and then runs the command meant for // the host right here, with the home directory pointed at a directory // standing in for the host's, so that what keyfunc sends can be // watched doing its work. const installer = ` while [ $# -gt 1 ]; do printf '%s\n' "$1" >> "$KEYFUNC_TEST_ARGUMENTS" shift done printf '%s' "$1" > "$KEYFUNC_TEST_COMMAND" HOME="$KEYFUNC_TEST_HOME" export HOME eval "$1" ` // caller is a stand-in for the system ssh for the to command. It // writes down the arguments it was given, notes the agent socket if // there really is one at the path it was handed, and ends with the // status the test asked for. const caller = ` for argument in "$@"; do printf '%s\n' "$argument" >> "$KEYFUNC_TEST_ARGUMENTS" done socket=${2#IdentityAgent=} if [ -S "$socket" ]; then printf '%s\n' "$socket" > "$KEYFUNC_TEST_SOCKET" fi exit "$KEYFUNC_TEST_STATUS" ` // pretended is where a stand-in ssh writes down what it was asked to // do. type pretended struct { // home stands in for the home directory on the host. home string // arguments holds what ssh was given before the command, one per // line. arguments string // command holds what ssh was told to run on the host. command string } func TestTheKeyIsAddedToTheHostAndThenLeftAlone(t *testing.T) { t.Setenv(mnemonic.Variable, example()) pretend := pretendHost(t) require.Equal(t, "added\n", run(t, "ssh", "install", host)) directory, err := os.Stat(filepath.Join(pretend.home, keptUnder)) require.NoError(t, err) require.Equal(t, os.FileMode(directoryMode), directory.Mode().Perm(), ) path := filepath.Join(pretend.home, keptUnder, keptIn) file, err := os.Stat(path) require.NoError(t, err) require.Equal(t, os.FileMode(fileMode), file.Mode().Perm()) added := read(t, path) require.Equal(t, vectorZero+" keyfunc/ssh/0\n", added) require.Equal(t, "already present\n", run(t, "ssh", "install", host)) require.Equal(t, added, read(t, path)) } func TestTheKeyDoesNotRunIntoALineWithNoNewlineAtItsEnd(t *testing.T) { t.Setenv(mnemonic.Variable, example()) pretend := pretendHost(t) already := "ssh-ed25519 AAAAsomebodyelse somebody@else" require.NoError(t, os.Mkdir(filepath.Join(pretend.home, keptUnder), directoryMode), ) path := filepath.Join(pretend.home, keptUnder, keptIn) require.NoError(t, os.WriteFile(path, []byte(already), fileMode)) require.Equal(t, "added\n", run(t, "ssh", "install", host)) require.Equal(t, already+"\n"+vectorZero+" keyfunc/ssh/0\n", read(t, path), ) } func TestTheKeyLineIsNotOnTheCommandLine(t *testing.T) { t.Setenv(mnemonic.Variable, example()) pretend := pretendHost(t) run(t, "ssh", "install", host) require.NotContains(t, read(t, pretend.arguments), "ssh-ed25519") require.NotContains(t, read(t, pretend.command), "ssh-ed25519") } func TestWhatComesAfterTheDashesIsGivenToSSH(t *testing.T) { t.Setenv(mnemonic.Variable, example()) pretend := pretendHost(t) run(t, "ssh", "install", host, "--", "-p", "2222") require.Equal(t, []string{"-p", "2222", host}, recorded(t, pretend.arguments), ) } func TestSSHIsPointedAtTheAgentAndItsStatusIsHandedOn(t *testing.T) { t.Setenv(mnemonic.Variable, example()) arguments, noted := pretendCall(t) _, err := execute(t, "ssh", "to", host, "uptime") var passed ssh.StatusError require.ErrorAs(t, err, &passed) require.Equal(t, failingStatus, passed.Status) given := recorded(t, arguments) require.Equal(t, "-o", given[0]) require.Equal(t, []string{host, "uptime"}, given[2:]) // The stand-in wrote the path down only because there really was // a socket there while it ran. socket := strings.TrimSpace(read(t, noted)) require.Equal(t, "IdentityAgent="+socket, given[1]) require.NoDirExists(t, filepath.Dir(socket)) } func TestTheToolEndsWithTheStatusSSHEndedWith(t *testing.T) { t.Setenv(mnemonic.Variable, example()) pretendCall(t) given := os.Args t.Cleanup(func() { os.Args = given }) os.Args = []string{"keyfunc", "ssh", "to", host, "uptime"} require.Equal(t, failingStatus, cli.Main()) } // pretendHost puts the install stand-in on the path and gives back the // places it writes to. func pretendHost(t *testing.T) pretended { t.Helper() pretend := pretended{ home: t.TempDir(), arguments: filepath.Join(t.TempDir(), "arguments"), command: filepath.Join(t.TempDir(), "command"), } t.Setenv("KEYFUNC_TEST_HOME", pretend.home) t.Setenv("KEYFUNC_TEST_ARGUMENTS", pretend.arguments) t.Setenv("KEYFUNC_TEST_COMMAND", pretend.command) standIn(t, installer) return pretend } // pretendCall puts the to stand-in on the path and gives back the file // the arguments are written down in and the file the agent socket is // noted in. func pretendCall(t *testing.T) (string, string) { t.Helper() arguments := filepath.Join(t.TempDir(), "arguments") noted := filepath.Join(t.TempDir(), "socket") t.Setenv("KEYFUNC_TEST_ARGUMENTS", arguments) t.Setenv("KEYFUNC_TEST_SOCKET", noted) t.Setenv("KEYFUNC_TEST_STATUS", strconv.Itoa(failingStatus)) standIn(t, caller) return arguments, noted } // standIn writes a stand-in for the system ssh and puts it first on // the path, so that the tool finds it instead of the real one. func standIn(t *testing.T, body string) { t.Helper() directory := t.TempDir() err := os.WriteFile( filepath.Join(directory, "ssh"), []byte("#!/bin/sh\n"+body), standInMode, ) require.NoError(t, err) t.Setenv("PATH", directory+string(os.PathListSeparator)+os.Getenv("PATH"), ) } // read returns what is in a file. func read(t *testing.T, path string) string { t.Helper() //nolint:gosec // the path is a temporary file of the test's own content, err := os.ReadFile(path) require.NoError(t, err) return string(content) } // recorded returns the arguments a stand-in wrote down, one per line. func recorded(t *testing.T, path string) []string { t.Helper() return strings.Split(strings.TrimSuffix(read(t, path), "\n"), "\n") }