check / check (push) Successful in 1m8s
Adding a PGP unlocker checked unlockers.d for a duplicate and, when the directory could not be read, reported no duplicate and went on. The check now returns an error naming the directory and cause, and the add stops; a duplicate found is still reported as one. The same flaw guarded removing the last unlocker and removing a vault (an unreadable secrets directory counted as no secrets) and vault import (an unreadable pub.age counted as no long-term key). Those now stop with an error too. `unlocker list` keeps skipping entries it cannot read; a comment at the duplicate check says why the two differ. Model: opus-5-5
263 lines
8.2 KiB
Go
263 lines
8.2 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?), 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.
|
|
|
|
//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)
|
|
}
|
|
|
|
// 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, and returns the key's fingerprint.
|
|
func newTestGPGKey(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
t.Setenv("GNUPGHOME", t.TempDir())
|
|
|
|
t.Cleanup(func() {
|
|
// Stop the gpg-agent that key generation starts. 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)
|
|
|
|
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
|
|
// fails, and creates no unlocker directory, when unlockers.d cannot be
|
|
// read for the duplicate check; and, as the control case, that a readable
|
|
// unlockers.d holding the same key is still refused as a duplicate.
|
|
//
|
|
//nolint:paralleltest // t.Setenv (GNUPGHOME) forbids parallel tests
|
|
func TestAddPGPUnlockerDuplicateCheck(t *testing.T) {
|
|
fingerprint := newTestGPGKey(t)
|
|
unlockersDir := filepath.Join(
|
|
testVaultDir(listTestVaultName), listTestUnlockersDirName)
|
|
|
|
tests := []struct {
|
|
name string
|
|
openBudget int
|
|
}{
|
|
// The vault's own enumeration of unlockers.d fails.
|
|
{name: "listing fails", openBudget: 0},
|
|
// The enumeration succeeds; the rescan that resolves IDs fails.
|
|
{name: "rescan fails", openBudget: 1},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
base := newListTestVault(t, 1)
|
|
fs := &unlockersDirFailFs{Fs: base, openBudget: tt.openBudget}
|
|
|
|
err := addTestPGPUnlocker(fs)
|
|
|
|
require.ErrorIs(t, err, errUnlockersDirUnreadable)
|
|
require.NotErrorIs(t, err, errGPGKeyAlreadyUnlocker)
|
|
assert.Contains(t, err.Error(), unlockersDir,
|
|
"the error must name the directory it could not read")
|
|
assertDirEntries(t, base, unlockersDir, listTestUnlockerDirOne)
|
|
})
|
|
}
|
|
|
|
t.Run("duplicate refused", func(t *testing.T) {
|
|
base := newListTestVault(t, 1)
|
|
writePGPUnlocker(t, base, unlockersDir, listTestUnlockerDirTwo,
|
|
time.Date(2026, time.August, 10, 12, 30, 0, 0, time.UTC),
|
|
fingerprint)
|
|
|
|
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, cmd := newTestInstance(&statFailFs{Fs: base, path: path})
|
|
|
|
err := instance.UnlockersRemove(
|
|
"pgp-"+listTestGPGKeyID+"A", false, cmd)
|
|
|
|
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.
|
|
func TestRemoveVaultAbortsWhenSecretsDirUnreadable(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
base := newListTestVault(t, 1)
|
|
vaultDir := testVaultDir(unreadableTestOtherVault)
|
|
writeTestSecret(t, base, vaultDir)
|
|
instance, cmd := newTestInstance(&statFailFs{
|
|
Fs: base, path: filepath.Join(vaultDir, unreadableTestSecretsDirName),
|
|
})
|
|
|
|
err := instance.RemoveVault(cmd, unreadableTestOtherVault, false)
|
|
|
|
require.ErrorIs(t, err, errStatFailed)
|
|
|
|
exists, err := afero.DirExists(base, vaultDir)
|
|
require.NoError(t, err)
|
|
assert.True(t, exists, "the vault 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.VaultImport(cmd, listTestVaultName)
|
|
|
|
require.ErrorIs(t, err, errStatFailed)
|
|
}
|