Files
secret/internal/cli/unlock_failure_test.go
T
sneak aefec9d8e8
check / check (push) Failing after 3s
Rename the Go module to sneak.berlin/go/secret (closes #43)
REPO_POLICIES.md requires the module root sneak.berlin/go/<name>.
go.mod, every import (rewritten with gofmt -r), the -X flags in
script/build and the examples in the pkg READMEs now use the new path.
go mod tidy lists go-humanize and fatih/color as direct requirements,
since internal/cli imports them.

This breaks anyone who fetched or imported git.eeqj.de/sneak/secret:
they must switch to sneak.berlin/go/secret, which resolves to this
repository.

Model: opus-5-5
2026-10-05 00:10:56 +00:00

375 lines
12 KiB
Go

// Unlock Failure Tests
//
// When a vault cannot be opened through its current unlocker, because a
// file the unlocker needs is missing or the passphrase is wrong, the error
// keeps its cause and ends by saying that the mnemonic still opens that
// vault, but only for a vault that the mnemonic does open, and not when the
// passphrase could not be read at all. When a secret's current file is
// missing, the error says how to make a version current again. Each test
// that pins such advice also follows it.
package cli_test
import (
"bytes"
"io"
"os"
"os/exec"
"path/filepath"
"testing"
"filippo.io/age"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/cli"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
)
const (
// mnemonicAdvice ends the error when the current vault "default", which
// its mnemonic opens, cannot be opened through its current unlocker.
mnemonicAdvice = "; the vault 'default' still opens with its mnemonic: " +
"run 'secret unlocker add passphrase' with SB_SECRET_MNEMONIC set " +
"to the mnemonic to give it a new unlocker"
// versionAdvice ends the error when a secret's current file cannot be
// read.
versionAdvice = "; this file only names the current version: " +
"'secret version list' lists the secret's versions, and " +
"'secret version promote' makes one of them current"
// unlockTestVaultDir is the directory of the vault "default" of
// newTwoVaultFs, the current vault, whose secret "x" is "value".
unlockTestVaultDir = testStateDir + "/vaults.d/default"
)
// currentUnlockerDir returns the directory of the current unlocker of the
// vault in vaultDir on fs.
func currentUnlockerDir(t *testing.T, fs afero.Fs, vaultDir string) string {
t.Helper()
unlockerName, err := afero.ReadFile(fs,
filepath.Join(vaultDir, "current-unlocker"))
require.NoError(t, err)
return filepath.Join(vaultDir, "unlockers.d", string(unlockerName))
}
// newUnlockTestCLI returns the directory of the current unlocker of the
// vault "default" on fs, a copy of the vaults of newTwoVaultFs, and a CLI
// instance on fs that has the unlock passphrase, as from the environment,
// but not the mnemonic.
func newUnlockTestCLI(t *testing.T, fs afero.Fs) (string, *cli.Instance) {
t.Helper()
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
c.UnlockPassphrase = memguard.NewBufferFromBytes([]byte(testPassphrase))
t.Cleanup(c.UnlockPassphrase.Destroy)
return currentUnlockerDir(t, fs, unlockTestVaultDir), c
}
// discardCmd returns a command whose output is discarded.
func discardCmd() *cobra.Command {
cmd := &cobra.Command{}
cmd.SetOut(io.Discard)
return cmd
}
// getSecret returns what `secret get name` prints.
func getSecret(t *testing.T, c *cli.Instance, name string) string {
t.Helper()
var out bytes.Buffer
cmd := &cobra.Command{}
cmd.SetOut(&out)
require.NoError(t, c.GetSecret(cmd, name))
return out.String()
}
// TestUnlockFailureNamesMnemonic checks the error of `secret get` when a
// file that opening the vault through its current unlocker needs is
// missing: it keeps the cause, which names the file, and ends with the
// advice that the mnemonic still opens the vault. The test then follows
// that advice: `secret unlocker add passphrase`, with the mnemonic, gives
// the vault a new unlocker, which opens it.
func TestUnlockFailureNamesMnemonic(t *testing.T) {
t.Parallel()
tests := []struct {
file string // the file removed
inVaultDir bool // the file is the vault's, not the unlocker's
want string // the message before the cause
}{
{
file: "current-unlocker",
inVaultDir: true,
want: "failed to unlock vault: failed to get long-term key: " +
"failed to get current unlocker: " +
"failed to read current unlocker: ",
},
{
file: "priv.age",
want: "failed to unlock vault: failed to get long-term key: " +
"failed to get unlocker identity: " +
"failed to read unlocker private key: ",
},
{
file: "longterm.age",
want: "failed to unlock vault: failed to get long-term key: " +
"failed to read encrypted long-term private key: ",
},
}
for _, tt := range tests {
t.Run(tt.file, func(t *testing.T) {
t.Parallel()
fs := newTwoVaultFs(t)
unlockerDir, c := newUnlockTestCLI(t, fs)
path := filepath.Join(unlockerDir, tt.file)
if tt.inVaultDir {
path = filepath.Join(unlockTestVaultDir, tt.file)
}
require.NoError(t, fs.Remove(path))
err := c.GetSecret(discardCmd(), "x")
var cause *os.PathError
require.ErrorAs(t, err, &cause)
require.ErrorIs(t, err, os.ErrNotExist)
assert.Equal(t, path, cause.Path)
require.EqualError(t, err, tt.want+cause.Error()+mnemonicAdvice)
c.Mnemonic = testMnemonicBuffer(t)
require.NoError(t, c.UnlockersAdd("passphrase", discardCmd()))
c.Mnemonic = nil
assert.Equal(t, "value", getSecret(t, c, "x"))
})
}
}
// TestWrongPassphraseNamesMnemonic checks the error of `secret get` given a
// passphrase that does not decrypt the passphrase unlocker: it keeps age's
// error and ends with the advice that the mnemonic still opens the vault.
func TestWrongPassphraseNamesMnemonic(t *testing.T) {
t.Parallel()
_, c := newUnlockTestCLI(t, newTwoVaultFs(t))
c.UnlockPassphrase = memguard.NewBufferFromBytes([]byte("wrong passphrase"))
t.Cleanup(c.UnlockPassphrase.Destroy)
err := c.GetSecret(discardCmd(), "x")
var noMatch *age.NoIdentityMatchError
require.ErrorAs(t, err, &noMatch)
require.EqualError(t, err, "failed to unlock vault: "+
"failed to get long-term key: failed to get unlocker identity: "+
"failed to decrypt unlocker private key: failed to create decryptor: "+
noMatch.Error()+mnemonicAdvice)
}
// TestMoveUnlockFailureNamesVault checks the error of `secret move` into
// the vault "work", which is not the current vault, when "work" cannot be
// opened through its current unlocker: the advice names "work" and says to
// select it first, since `secret unlocker add` acts on the current vault.
// The test then follows that advice, and the move succeeds.
func TestMoveUnlockFailureNamesVault(t *testing.T) {
t.Parallel()
fs := newTwoVaultFs(t)
_, c := newUnlockTestCLI(t, fs)
path := filepath.Join(
currentUnlockerDir(t, fs, testStateDir+"/vaults.d/work"), "priv.age")
require.NoError(t, fs.Remove(path))
err := c.MoveSecret(discardCmd(), "default:x", "work:y", false)
var cause *os.PathError
require.ErrorAs(t, err, &cause)
assert.Equal(t, path, cause.Path)
require.EqualError(t, err, "failed to unlock destination vault 'work': "+
"failed to get unlocker identity: failed to read unlocker private key: "+
cause.Error()+"; the vault 'work' still opens with its mnemonic: "+
"run 'secret vault select work', then 'secret unlocker add passphrase' "+
"with SB_SECRET_MNEMONIC set to the mnemonic to give it a new unlocker")
require.NoError(t, c.SelectVault(discardCmd(), "work"))
c.Mnemonic = testMnemonicBuffer(t)
require.NoError(t, c.UnlockersAdd("passphrase", discardCmd()))
c.Mnemonic = nil
require.NoError(t, c.MoveSecret(discardCmd(), "default:x", "work:y", false))
assert.Equal(t, "value", getSecret(t, c, "y"))
}
// TestPassphraseNotReadNamesNoMnemonic runs `secret get x` on the built
// binary without SB_UNLOCK_PASSPHRASE and with a stdin that is not a
// terminal, so the passphrase cannot be read. The unlocker was not tried,
// and adding one would need a passphrase read the same way, so the error
// is the cause alone, without the advice to use the mnemonic.
func TestPassphraseNotReadNamesNoMnemonic(t *testing.T) {
t.Parallel()
stateDir := t.TempDir()
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
defer mnemonic.Destroy()
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
defer passphrase.Destroy()
vlt, err := vault.CreateVault(
afero.NewOsFs(), stateDir, "default", mnemonic, passphrase)
require.NoError(t, err)
value := memguard.NewBufferFromBytes([]byte("value"))
defer value.Destroy()
require.NoError(t, vlt.AddSecret("x", value, false))
//nolint:gosec // G204: test executes the freshly built secret binary
cmd := exec.CommandContext(t.Context(), secretBinaryPath(t), "get", "x")
cmd.Env = []string{
secret.EnvStateDir + "=" + stateDir,
"PATH=" + os.Getenv("PATH"),
"HOME=" + os.Getenv("HOME"),
}
output, err := cmd.CombinedOutput()
require.Error(t, err)
assert.Equal(t, "Error: failed to unlock vault: "+
"failed to get long-term key: failed to get unlocker identity: "+
"failed to read passphrase: stdin is not a terminal (piped input or "+
"script). Please set the SB_UNLOCK_PASSPHRASE environment variable or "+
"run interactively\n", string(output))
}
// TestCryptoUnlockFailureNamesMnemonic checks that `secret encrypt` and
// `secret decrypt`, reading the key secret, end with the same advice as
// `secret get` when the vault cannot be opened through its current
// unlocker.
func TestCryptoUnlockFailureNamesMnemonic(t *testing.T) {
t.Parallel()
tests := []struct {
command string
run func(c *cli.Instance) error
}{
{"encrypt", func(c *cli.Instance) error { return c.Encrypt("x", "", "") }},
{"decrypt", func(c *cli.Instance) error { return c.Decrypt("x", "", "") }},
}
for _, tt := range tests {
t.Run(tt.command, func(t *testing.T) {
t.Parallel()
fs := newTwoVaultFs(t)
unlockerDir, c := newUnlockTestCLI(t, fs)
path := filepath.Join(unlockerDir, "priv.age")
require.NoError(t, fs.Remove(path))
err := tt.run(c)
var cause *os.PathError
require.ErrorAs(t, err, &cause)
assert.Equal(t, path, cause.Path)
require.EqualError(t, err, "failed to get secret value: "+
"failed to unlock vault: failed to get long-term key: "+
"failed to get unlocker identity: "+
"failed to read unlocker private key: "+cause.Error()+
mnemonicAdvice)
})
}
}
// TestMissingCurrentFileNamesVersionCommands checks the error of `secret
// get` when the secret's current file is missing: it keeps the cause, which
// names the file, and ends with the advice that says how to make a version
// current again. The test then follows that advice.
func TestMissingCurrentFileNamesVersionCommands(t *testing.T) {
t.Parallel()
fs := newTwoVaultFs(t)
_, c := newUnlockTestCLI(t, fs)
secretDir := filepath.Join(unlockTestVaultDir, "secrets.d", "x")
path := filepath.Join(secretDir, "current")
require.NoError(t, fs.Remove(path))
err := c.GetSecret(discardCmd(), "x")
var cause *os.PathError
require.ErrorAs(t, err, &cause)
require.ErrorIs(t, err, os.ErrNotExist)
assert.Equal(t, path, cause.Path)
require.EqualError(t, err, "failed to get current version: "+
"failed to read current version file: "+cause.Error()+versionAdvice)
versions, err := afero.ReadDir(fs, filepath.Join(secretDir, "versions"))
require.NoError(t, err)
require.Len(t, versions, 1)
var out bytes.Buffer
cmd := &cobra.Command{}
cmd.SetOut(&out)
require.NoError(t, c.ListVersions(cmd, "x"))
assert.Contains(t, out.String(), versions[0].Name())
require.NoError(t, c.PromoteVersion(cmd, "x", versions[0].Name()))
assert.Equal(t, "value", getSecret(t, c, "x"))
}
// TestUnlockFailureWithoutLongTermKeyNamesNoMnemonic checks that a vault
// created without a mnemonic, which no mnemonic opens, gets no advice to
// use one: `secret unlocker add passphrase` there fails with the cause
// alone.
func TestUnlockFailureWithoutLongTermKeyNamesNoMnemonic(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
_, err := vault.CreateVault(fs, testStateDir, "keyless", nil, nil)
require.NoError(t, err)
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
c.UnlockPassphrase = memguard.NewBufferFromBytes([]byte(testPassphrase))
t.Cleanup(c.UnlockPassphrase.Destroy)
err = c.UnlockersAdd("passphrase", discardCmd())
var cause *os.PathError
require.ErrorAs(t, err, &cause)
require.EqualError(t, err, "failed to get long-term key: "+
"failed to get current unlocker: failed to read current unlocker: "+
cause.Error())
}