// 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) }