package cli_test import ( "io" "os" "os/exec" "path/filepath" "strings" "syscall" "testing" "time" "github.com/stretchr/testify/require" "sneak.berlin/go/keyfunc/internal/agekey" "sneak.berlin/go/keyfunc/internal/mnemonic" ) func TestTheAgeCommandsPrintTheKey(t *testing.T) { t.Setenv(mnemonic.Variable, example()) recipient := strings.TrimSpace(run(t, "age", "pub")) require.True(t, strings.HasPrefix(recipient, "age1")) identity := strings.TrimSpace(run(t, "age", "priv")) require.True(t, strings.HasPrefix(identity, "AGE-SECRET-KEY-1")) } func TestAFileEncryptedByTheToolIsReadBackByIt(t *testing.T) { t.Setenv(mnemonic.Variable, example()) plain := written(t, "notes.txt", "the secret\n") sealed := filepath.Join(t.TempDir(), "notes.age") run(t, "age", "encrypt", "-o", sealed, plain) require.Equal(t, "the secret\n", run(t, "age", "decrypt", sealed)) } func TestTheArmoredFormIsTextThatDecrypts(t *testing.T) { t.Setenv(mnemonic.Variable, example()) plain := written(t, "notes.txt", "the secret\n") armored := run(t, "age", "encrypt", "--armor", plain) require.True(t, strings.HasPrefix( armored, "-----BEGIN AGE ENCRYPTED FILE-----", )) sealed := written(t, "notes.age", armored) require.Equal(t, "the secret\n", run(t, "age", "decrypt", sealed)) } func TestAnotherRecipientIsAddedAndTheDerivedOneStays(t *testing.T) { t.Setenv(mnemonic.Variable, example()) theirs := strings.TrimSpace(run(t, "age", "pub", "-n", "7")) plain := written(t, "notes.txt", "the secret\n") sealed := filepath.Join(t.TempDir(), "notes.age") run(t, "age", "encrypt", "--to", theirs, "-o", sealed, plain) require.Equal(t, "the secret\n", run(t, "age", "decrypt", sealed)) require.Equal(t, "the secret\n", run(t, "age", "decrypt", "-n", "7", sealed), ) } func TestAFileForAnotherKeyIsRefused(t *testing.T) { t.Setenv(mnemonic.Variable, example()) plain := written(t, "notes.txt", "the secret\n") sealed := filepath.Join(t.TempDir(), "notes.age") run(t, "age", "encrypt", "-n", "7", "-o", sealed, plain) _, err := execute(t, "age", "decrypt", sealed) require.ErrorIs(t, err, agekey.ErrNotRecipient) } func TestARefusedDecryptionLeavesTheOutputFileAlone(t *testing.T) { t.Setenv(mnemonic.Variable, example()) plain := written(t, "notes.txt", "the secret\n") sealed := filepath.Join(t.TempDir(), "notes.age") existing := written(t, "notes.out", "what was already there\n") run(t, "age", "encrypt", "-n", "7", "-o", sealed, plain) _, err := execute(t, "age", "decrypt", "-o", existing, sealed) require.ErrorIs(t, err, agekey.ErrNotRecipient) //nolint:gosec // the test made this path itself kept, err := os.ReadFile(existing) require.NoError(t, err) require.Equal(t, "what was already there\n", string(kept)) } func TestASignalStopsAnEncryptionAndLeavesNoFile(t *testing.T) { t.Setenv(mnemonic.Variable, example()) for _, ending := range []os.Signal{ syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP, } { interrupted(t, ending, "encrypt", "the start of the secret\n", false) } } func TestASignalStopsADecryptionAndLeavesNoFile(t *testing.T) { t.Setenv(mnemonic.Variable, example()) // All of an encryption but its last byte, so the tool reads the // header and then waits for the rest. sealed := run(t, "age", "encrypt", written(t, "notes.txt", "the secret\n")) cut := sealed[:len(sealed)-1] for _, ending := range []os.Signal{ syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP, } { interrupted(t, ending, "decrypt", cut, false) } } func TestASignalAsTheInputEndsLeavesNoFile(t *testing.T) { t.Setenv(mnemonic.Variable, example()) sealed := run(t, "age", "encrypt", written(t, "notes.txt", "the secret\n")) // Ctrl-C on "producer | keyfunc age encrypt -o file" ends the // producer too, so the input ends just as the signal comes, with // enough of it in hand for a whole encryption or decryption. Which // of the two reaches the tool first varies, so it is tried often. for range 25 { interrupted(t, syscall.SIGINT, "encrypt", "the start of the secret\n", true) interrupted(t, syscall.SIGINT, "decrypt", sealed, true) } } func TestAnEncryptionStartedUnderNohupSurvivesAHangup(t *testing.T) { t.Setenv(mnemonic.Variable, example()) directory := t.TempDir() named := filepath.Join(directory, "notes") // nohup starts the tool with SIGHUP ignored. A tool that caught it // anyway would turn it back on and be ended by it. command, producer := writing( t, directory, "the secret\n", "nohup", os.Args[0], "age", "encrypt", "-o", named, ) require.NoError(t, command.Process.Signal(syscall.SIGHUP)) require.NoError(t, producer.Close()) waitForTool(t, "SIGHUP under nohup", command) require.Equal(t, 0, command.ProcessState.ExitCode()) left, err := os.ReadDir(directory) require.NoError(t, err) require.Len(t, left, 1) require.Equal(t, "the secret\n", run(t, "age", "decrypt", named)) } // interrupted runs "age encrypt -o" or "age decrypt -o", as the // operation says, writing into a directory of its own, and once it has // begun writing sends it the signal, then ends the input if endInput // says so and otherwise leaves it open. The tool has to end with status // 1 and leave the directory empty. A tool that went on reading would // not end until the input did; one that did not remove the file it was // writing would leave it there, with what it had written so far; one // that put that file in place because the input ended would leave the // named file. func interrupted( t *testing.T, signal os.Signal, operation, input string, endInput bool, ) { t.Helper() name := operation + " " + signal.String() directory := t.TempDir() command, producer := writing( t, directory, input, os.Args[0], "age", operation, "-o", filepath.Join(directory, "notes"), ) require.NoError(t, command.Process.Signal(signal)) if endInput { require.NoError(t, producer.Close()) } waitForTool(t, name, command) require.Equal(t, 1, command.ProcessState.ExitCode(), name) left, err := os.ReadDir(directory) require.NoError(t, err) require.Empty(t, left, name) } // writing starts argv, the tool told to write into directory, as a // subprocess reading the input from a pipe, and returns once the tool // has begun writing the file beside the one it was named. The pipe is // left open for the caller to end. func writing( t *testing.T, directory, input string, argv ...string, ) (*exec.Cmd, io.WriteCloser) { t.Helper() //nolint:gosec // this test's own binary as the tool, or nohup running it command := exec.CommandContext(t.Context(), argv[0], argv[1:]...) command.Env = append(os.Environ(), runAsTool+"=1") producer, err := command.StdinPipe() require.NoError(t, err) require.NoError(t, command.Start()) _, err = io.WriteString(producer, input) require.NoError(t, err) // The file beside the named one is made once the mnemonic has been // read, before any input is. require.Eventually(t, func() bool { entries, err := os.ReadDir(directory) return err == nil && len(entries) > 0 }, 5*time.Second, 5*time.Millisecond) return command, producer } // written puts the contents in a file of that name in a directory of // this test's own and returns the path to it. func written(t *testing.T, name, contents string) string { t.Helper() path := filepath.Join(t.TempDir(), name) require.NoError(t, os.WriteFile(path, []byte(contents), 0o600)) return path }