check / check (push) Waiting to run
init, vault create, unlocker add, and add, import and generate secret with --force asked for the mnemonic or passphrase, or unlocked the vault, while holding the state directory lock, so every other command that changes anything waited at the prompt. They now ask and unlock first and lock after. Under the lock, unlocker add fails if the vault was removed, or created again with another key, and refuses a GPG key added meanwhile; a forced add of a secret created meanwhile lets go of the lock, gets the key and locks again. secret.CreatePGPUnlocker takes the unlocked vault; SetMnemonic and SetUnlockPassphrase are gone. Not done: secret move between vaults still unlocks both under the lock (#189). Model: opus-5-5
375 lines
12 KiB
Go
375 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 a
|
|
// function that makes the look before any lock is taken: addPGPUnlocker for
|
|
// UnlockersAdd, which looks again once it holds the lock, 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"
|
|
|
|
"github.com/spf13/afero"
|
|
"github.com/spf13/cobra"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"sneak.berlin/go/secret/internal/secret"
|
|
)
|
|
|
|
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")
|
|
}
|