Files
keyfunc/internal/cli/age_test.go
T
sneak a31a03b2c3
check / check (push) Successful in 2m20s
Signals end every command, not only ssh to and ssh install (closes #48)
SIGINT, SIGTERM and SIGHUP were caught for the whole run, but only the
ssh and sftp children acted on them: the mnemonic prompt waited for
Enter, and an interrupted `age encrypt -o` went on to put the
encryption of the cut-off input in place. Now `ssh to` and
`ssh install` catch them from once the mnemonic is read until their
cleanup has run, and `age encrypt -o` and `age decrypt -o` catch them
while they write, to remove the unfinished file and exit with status 1;
everywhere else they end the tool at once. Tests cover an interrupted
`age encrypt -o` and `age decrypt -o` and the install working directory
on a signal.

Model: opus-5-5
2026-10-04 05:32:51 +00:00

179 lines
5.3 KiB
Go

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")
}
}
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)
}
}
// interrupted runs "age encrypt -o" or "age decrypt -o", as the
// operation says, as a subprocess writing into a directory of its own
// and reading the input from a pipe that stays open. It waits until the
// tool has begun writing the file beside the one it was named, and
// sends it the signal. The tool has to end on the signal alone, 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.
func interrupted(t *testing.T, signal os.Signal, operation, input string) {
t.Helper()
name := operation + " " + signal.String()
directory := t.TempDir()
//nolint:gosec // the binary is this test's own, re-run as the tool
command := exec.CommandContext(
t.Context(), os.Args[0], "age", operation,
"-o", filepath.Join(directory, "notes"),
)
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)
require.NoError(t, command.Process.Signal(signal))
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)
}
// 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
}