check / check (push) Failing after 2s
Keychain and Secure Enclave unlocker IDs were the creation time to the minute plus the host name, and passphrase unlocker IDs the time to the minute, so two created within one minute shared an ID, and `unlocker select`, `unlocker remove` and the selection after `unlocker add` acted on the older one. Every unlocker's ID is now its directory name, unique in its vault. `vault.ListUnlockers` returns each unlocker's metadata keyed by that name, so `unlocker list` and shell completion no longer find IDs by matching metadata. PGP unlocker IDs were `pgp-<fingerprint>`; a second PGP unlocker for one key is refused by comparing fingerprints in metadata. 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(listTestUnlockerDirOne)
|
|
|
|
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)
|
|
}
|