check / check (push) Failing after 2s
secret rm, secret version rm, secret vault remove and secret unlocker remove ask [y/N] on a terminal, naming what they remove, and go ahead only on y or yes. Without --force, a command whose stdin is not a terminal fails at once. --force, now also on rm and version rm, removes without asking; it replaces the old refusals to remove a vault with secrets or the last unlocker without --force. The checks run and the question is asked before the state directory lock is taken; under the lock the checks run again, and nothing is removed if they would ask a different question. Model: opus-5-5
390 lines
12 KiB
Go
390 lines
12 KiB
Go
// Unreadable Directory Tests
|
|
//
|
|
// The checks that guard adding a PGP unlocker (is this key already an
|
|
// unlocker?), removing the last unlocker and removing a vault (does the
|
|
// vault hold secrets?), removing a secret (how many versions does it
|
|
// have?), and importing a mnemonic (does the vault already have a
|
|
// long-term key?) each look at the vault on disk before acting.
|
|
// When that look fails they must refuse to act, not read the failure as
|
|
// "nothing there" and go ahead.
|
|
//
|
|
// The tests make the look fail with a wrapper around the in-memory
|
|
// filesystem, which the state directory lock refuses. So they call the
|
|
// function each command runs once it holds the lock, such as addPGPUnlocker
|
|
// for UnlockersAdd, or, for a removal, the function that makes its checks,
|
|
// such as findVaultToRemove for RemoveVault, which runs again under the
|
|
// lock before anything is removed, with --force or without.
|
|
|
|
//nolint:testpackage // white-box test of unexported internals
|
|
package cli
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.eeqj.de/sneak/secret/internal/secret"
|
|
"github.com/spf13/afero"
|
|
"github.com/spf13/cobra"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
const (
|
|
// unreadableTestGPGUserID is the user ID of the throwaway GPG key the
|
|
// PGP unlocker tests generate, and the --keyid they pass.
|
|
unreadableTestGPGUserID = "unlocker-test@example.com"
|
|
|
|
// unreadableTestSecretName is the secret stored in the vaults the
|
|
// removal tests remove from.
|
|
unreadableTestSecretName = "api-key"
|
|
|
|
// unreadableTestOtherVault is a second vault for the vault removal
|
|
// test, since the last vault can never be removed.
|
|
unreadableTestOtherVault = "work"
|
|
|
|
// unreadableTestSecretsDirName is the directory holding a vault's
|
|
// secrets, and unreadableTestCurrentFileName the per-secret file
|
|
// naming its current version.
|
|
unreadableTestSecretsDirName = "secrets.d"
|
|
unreadableTestCurrentFileName = "current"
|
|
)
|
|
|
|
// errStatFailed is returned by statFailFs in place of a successful stat.
|
|
var errStatFailed = errors.New("input/output error")
|
|
|
|
// statFailFs fails every Stat of one path, as an I/O or permission error
|
|
// on that path would.
|
|
type statFailFs struct {
|
|
afero.Fs
|
|
|
|
path string
|
|
}
|
|
|
|
func (f *statFailFs) Stat(name string) (os.FileInfo, error) {
|
|
if name == f.path {
|
|
return nil, errStatFailed
|
|
}
|
|
|
|
return f.Fs.Stat(name)
|
|
}
|
|
|
|
// errOpenFailed is returned by openFailFs in place of a successful open.
|
|
var errOpenFailed = errors.New("permission denied")
|
|
|
|
// openFailFs fails every Open of one path, as a directory without read
|
|
// permission does: checking that it exists succeeds, listing it fails.
|
|
type openFailFs struct {
|
|
afero.Fs
|
|
|
|
path string
|
|
}
|
|
|
|
//nolint:ireturn // afero.File is the interface required by afero.Fs
|
|
func (f *openFailFs) Open(name string) (afero.File, error) {
|
|
if name == f.path {
|
|
return nil, errOpenFailed
|
|
}
|
|
|
|
return f.Fs.Open(name)
|
|
}
|
|
|
|
// testVaultDir returns the directory of the named vault in the synthetic
|
|
// state directory built by newListTestVault.
|
|
func testVaultDir(vaultName string) string {
|
|
return filepath.Join(listTestStateDir, "vaults.d", vaultName)
|
|
}
|
|
|
|
// newTestInstance returns a CLI instance on fs whose output is discarded.
|
|
func newTestInstance(fs afero.Fs) (*Instance, *cobra.Command) {
|
|
cmd := &cobra.Command{}
|
|
cmd.SetOut(io.Discard)
|
|
cmd.SetErr(io.Discard)
|
|
|
|
return &Instance{fs: fs, stateDir: listTestStateDir, cmd: cmd}, cmd
|
|
}
|
|
|
|
// assertDirEntries asserts that dir holds exactly the named entries.
|
|
func assertDirEntries(t *testing.T, fs afero.Fs, dir string, want ...string) {
|
|
t.Helper()
|
|
|
|
entries, err := afero.ReadDir(fs, dir)
|
|
require.NoError(t, err)
|
|
|
|
names := make([]string, 0, len(entries))
|
|
for _, entry := range entries {
|
|
names = append(names, entry.Name())
|
|
}
|
|
|
|
assert.ElementsMatch(t, want, names)
|
|
}
|
|
|
|
// newTestGPGKey points GNUPGHOME at a fresh directory, generates a GPG key
|
|
// without a passphrase there, with a subkey for encryption, and returns the
|
|
// key's fingerprint.
|
|
func newTestGPGKey(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
// Not t.TempDir(): on macOS its path is too long for the gpg-agent
|
|
// socket, which is created inside GNUPGHOME there.
|
|
gnupgHome, err := os.MkdirTemp("", "gpg") //nolint:usetesting // short path
|
|
require.NoError(t, err)
|
|
|
|
t.Cleanup(func() { _ = os.RemoveAll(gnupgHome) })
|
|
t.Setenv("GNUPGHOME", gnupgHome)
|
|
|
|
t.Cleanup(func() {
|
|
// Stop the gpg-agent that key generation starts; cleanups run in
|
|
// reverse order, so this happens before its directory is removed.
|
|
// t.Context is already canceled when cleanup runs.
|
|
ctx := context.WithoutCancel(t.Context())
|
|
_ = exec.CommandContext(ctx, "gpgconf", "--kill", "gpg-agent").Run()
|
|
})
|
|
|
|
output, err := exec.CommandContext(t.Context(), "gpg", "--batch",
|
|
"--pinentry-mode", "loopback", "--passphrase", "",
|
|
"--quick-gen-key", unreadableTestGPGUserID, "ed25519", "sign", "never",
|
|
).CombinedOutput()
|
|
require.NoError(t, err, "generating the test GPG key: %s", output)
|
|
|
|
fingerprint, err := secret.ResolveGPGKeyFingerprint(unreadableTestGPGUserID)
|
|
require.NoError(t, err)
|
|
|
|
//nolint:gosec // G204: fingerprint is the test key's, as gpg printed it
|
|
output, err = exec.CommandContext(t.Context(), "gpg", "--batch",
|
|
"--pinentry-mode", "loopback", "--passphrase", "",
|
|
"--quick-add-key", fingerprint, "cv25519", "encr", "never",
|
|
).CombinedOutput()
|
|
require.NoError(t, err, "adding the test GPG key's encryption subkey: %s",
|
|
output)
|
|
|
|
return fingerprint
|
|
}
|
|
|
|
// addTestPGPUnlocker runs `secret unlocker add pgp` for the test key
|
|
// against fs.
|
|
func addTestPGPUnlocker(fs afero.Fs) error {
|
|
instance, cmd := newTestInstance(fs)
|
|
cmd.Flags().String("keyid", unreadableTestGPGUserID, "")
|
|
|
|
return instance.addPGPUnlocker(cmd)
|
|
}
|
|
|
|
// TestAddPGPUnlockerDuplicateCheck asserts that adding a PGP unlocker for
|
|
// a key that already has one fails, and creates no unlocker directory,
|
|
// when unlockers.d or the existing unlocker's metadata file cannot be
|
|
// read; and, as the control case, that the existing unlocker is refused
|
|
// as a duplicate when everything can be read.
|
|
//
|
|
//nolint:paralleltest // t.Setenv (GNUPGHOME) forbids parallel tests
|
|
func TestAddPGPUnlockerDuplicateCheck(t *testing.T) {
|
|
fingerprint := newTestGPGKey(t)
|
|
unlockersDir := filepath.Join(
|
|
testVaultDir(listTestVaultName), listTestUnlockersDirName)
|
|
duplicateDir := filepath.Join(unlockersDir, listTestUnlockerDirTwo)
|
|
|
|
// newVaultWithDuplicate returns a vault holding an unlocker for the
|
|
// test key, beside the one newListTestVault writes.
|
|
newVaultWithDuplicate := func(t *testing.T) afero.Fs {
|
|
t.Helper()
|
|
|
|
base := newListTestVault(t, 1)
|
|
writePGPUnlocker(t, base, unlockersDir, listTestUnlockerDirTwo,
|
|
time.Date(2026, time.August, 10, 12, 30, 0, 0, time.UTC),
|
|
fingerprint)
|
|
|
|
return base
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
failFs func(base afero.Fs) afero.Fs
|
|
wantErr error
|
|
// wantPath is the path the error must name.
|
|
wantPath string
|
|
}{
|
|
{
|
|
name: "unlockers.d unreadable",
|
|
failFs: func(base afero.Fs) afero.Fs {
|
|
return &unlockersDirFailFs{Fs: base}
|
|
},
|
|
wantErr: errUnlockersDirUnreadable,
|
|
wantPath: unlockersDir,
|
|
},
|
|
{
|
|
name: "existing unlocker's metadata unreadable",
|
|
failFs: func(base afero.Fs) afero.Fs {
|
|
return &metadataReadFailFs{
|
|
Fs: base,
|
|
unreadablePath: filepath.Join(
|
|
duplicateDir, listTestMetadataFileName),
|
|
}
|
|
},
|
|
wantErr: errMetadataUnreadable,
|
|
wantPath: duplicateDir,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
base := newVaultWithDuplicate(t)
|
|
|
|
err := addTestPGPUnlocker(tt.failFs(base))
|
|
|
|
require.ErrorIs(t, err, tt.wantErr)
|
|
require.NotErrorIs(t, err, errGPGKeyAlreadyUnlocker)
|
|
assert.Contains(t, err.Error(), tt.wantPath,
|
|
"the error must name what it could not read")
|
|
assertDirEntries(t, base, unlockersDir,
|
|
listTestUnlockerDirOne, listTestUnlockerDirTwo)
|
|
})
|
|
}
|
|
|
|
t.Run("duplicate refused", func(t *testing.T) {
|
|
base := newVaultWithDuplicate(t)
|
|
|
|
err := addTestPGPUnlocker(base)
|
|
|
|
require.ErrorIs(t, err, errGPGKeyAlreadyUnlocker)
|
|
assertDirEntries(t, base, unlockersDir,
|
|
listTestUnlockerDirOne, listTestUnlockerDirTwo)
|
|
})
|
|
}
|
|
|
|
// writeTestSecret stores a secret with a current-version pointer, which is
|
|
// what makes it count as a secret, in the given vault directory.
|
|
func writeTestSecret(t *testing.T, fs afero.Fs, vaultDir string) {
|
|
t.Helper()
|
|
|
|
secretDir := filepath.Join(
|
|
vaultDir, unreadableTestSecretsDirName, unreadableTestSecretName)
|
|
require.NoError(t, fs.MkdirAll(secretDir, listTestDirPerm))
|
|
require.NoError(t, afero.WriteFile(fs,
|
|
filepath.Join(secretDir, unreadableTestCurrentFileName),
|
|
[]byte("20260809.001"), listTestFilePerm))
|
|
}
|
|
|
|
// TestRemoveLastUnlockerAbortsWhenSecretsUnreadable asserts that the last
|
|
// unlocker is kept when the secrets it protects cannot be counted.
|
|
func TestRemoveLastUnlockerAbortsWhenSecretsUnreadable(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
vaultDir := testVaultDir(listTestVaultName)
|
|
unlockersDir := filepath.Join(vaultDir, listTestUnlockersDirName)
|
|
secretsDir := filepath.Join(vaultDir, unreadableTestSecretsDirName)
|
|
|
|
for _, path := range []string{
|
|
secretsDir,
|
|
filepath.Join(secretsDir, unreadableTestSecretName,
|
|
unreadableTestCurrentFileName),
|
|
} {
|
|
t.Run(filepath.Base(path), func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
base := newListTestVault(t, 1)
|
|
writeTestSecret(t, base, vaultDir)
|
|
instance, _ := newTestInstance(&statFailFs{Fs: base, path: path})
|
|
|
|
_, err := instance.findUnlockerToRemove("pgp-" + listTestGPGKeyID + "A")
|
|
|
|
require.ErrorIs(t, err, errStatFailed)
|
|
assertDirEntries(t, base, unlockersDir, listTestUnlockerDirOne)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRemoveVaultAbortsWhenSecretsDirUnreadable asserts that a vault is
|
|
// kept when whether it holds secrets cannot be determined: when checking
|
|
// that secrets.d exists fails, and when it exists but cannot be listed.
|
|
func TestRemoveVaultAbortsWhenSecretsDirUnreadable(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
vaultDir := testVaultDir(unreadableTestOtherVault)
|
|
secretsDir := filepath.Join(vaultDir, unreadableTestSecretsDirName)
|
|
|
|
tests := []struct {
|
|
name string
|
|
failFs func(base afero.Fs) afero.Fs
|
|
wantErr error
|
|
}{
|
|
{
|
|
name: "check fails",
|
|
failFs: func(base afero.Fs) afero.Fs {
|
|
return &statFailFs{Fs: base, path: secretsDir}
|
|
},
|
|
wantErr: errStatFailed,
|
|
},
|
|
{
|
|
name: "listing fails",
|
|
failFs: func(base afero.Fs) afero.Fs {
|
|
return &openFailFs{Fs: base, path: secretsDir}
|
|
},
|
|
wantErr: errOpenFailed,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
base := newListTestVault(t, 1)
|
|
writeTestSecret(t, base, vaultDir)
|
|
instance, _ := newTestInstance(tt.failFs(base))
|
|
|
|
_, err := instance.findVaultToRemove(unreadableTestOtherVault)
|
|
|
|
require.ErrorIs(t, err, tt.wantErr)
|
|
|
|
exists, err := afero.DirExists(base, vaultDir)
|
|
require.NoError(t, err)
|
|
assert.True(t, exists, "the vault must not be removed")
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRemoveSecretAbortsWhenVersionsUnreadable asserts that a secret is
|
|
// kept when its versions directory exists but cannot be listed, so that
|
|
// the question cannot say how many versions would be removed.
|
|
func TestRemoveSecretAbortsWhenVersionsUnreadable(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
secretDir := filepath.Join(testVaultDir(listTestVaultName),
|
|
unreadableTestSecretsDirName, unreadableTestSecretName)
|
|
versionsDir := filepath.Join(secretDir, "versions")
|
|
|
|
base := newListTestVault(t, 1)
|
|
writeTestSecret(t, base, testVaultDir(listTestVaultName))
|
|
require.NoError(t, base.MkdirAll(versionsDir, listTestDirPerm))
|
|
|
|
instance, _ := newTestInstance(&openFailFs{Fs: base, path: versionsDir})
|
|
|
|
_, err := instance.findSecretToRemove(unreadableTestSecretName)
|
|
|
|
require.ErrorIs(t, err, errOpenFailed)
|
|
|
|
exists, err := afero.DirExists(base, secretDir)
|
|
require.NoError(t, err)
|
|
assert.True(t, exists, "the secret must not be removed")
|
|
}
|
|
|
|
// TestVaultImportAbortsWhenPubKeyUnreadable asserts that a mnemonic import
|
|
// stops when whether the vault already has a long-term key cannot be
|
|
// determined.
|
|
func TestVaultImportAbortsWhenPubKeyUnreadable(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
base := newListTestVault(t, 1)
|
|
instance, cmd := newTestInstance(&statFailFs{
|
|
Fs: base, path: filepath.Join(testVaultDir(listTestVaultName), "pub.age"),
|
|
})
|
|
|
|
err := instance.importMnemonic(cmd, listTestVaultName)
|
|
|
|
require.ErrorIs(t, err, errStatFailed)
|
|
}
|