check / check (push) Waiting to run
A command killed part-way could leave a temporary file or directory of secret.WriteFileAtomic or secret.TempDirFor, encrypted keys included, for good. LockStateDir now empties the lock file once it holds the lock and writes "finished" there just before releasing it. A holder that does not find that deletes such leftovers from the state directory, each vault, each secret and each version, the only places those helpers make them, matching names that start with "." and hold ".tmp-". After a command that finished nothing is searched, so the added time does not grow with the number of secrets and versions. A test shows that `unlocker remove` removes an unlocker directory with no metadata file. Model: opus-5-5
192 lines
6.2 KiB
Go
192 lines
6.2 KiB
Go
// Corrupt Unlocker Tests
|
|
//
|
|
// `secret unlocker select` and `secret unlocker remove` find an unlocker
|
|
// by its ID. These tests give the first unlocker, which sorts before the
|
|
// one the commands act on, metadata that is not JSON, and check that the
|
|
// commands step past it, and that it can itself be removed by its
|
|
// directory name, which `secret unlocker list` names in its warning, as can
|
|
// one with no metadata file. A last test checks that an unlocker whose
|
|
// metadata file cannot be read counts as the last unlocker when it is
|
|
// removed by its directory name.
|
|
|
|
//nolint:testpackage // white-box test of unexported internals
|
|
package cli
|
|
|
|
import (
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.eeqj.de/sneak/secret/internal/vault"
|
|
"github.com/spf13/afero"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// newCorruptUnlockerVault returns the two-unlocker test vault with the
|
|
// metadata of the first unlocker replaced by text that is not JSON.
|
|
func newCorruptUnlockerVault(t *testing.T) *afero.MemMapFs {
|
|
t.Helper()
|
|
|
|
fs := newListTestVault(t, 2)
|
|
require.NoError(t, afero.WriteFile(fs,
|
|
filepath.Join(testVaultDir(listTestVaultName), listTestUnlockersDirName,
|
|
listTestUnlockerDirOne, listTestMetadataFileName),
|
|
[]byte("not json"), listTestFilePerm))
|
|
|
|
return fs
|
|
}
|
|
|
|
// TestUnlockerSelectSkipsCorruptUnlocker asserts that the second unlocker
|
|
// can be selected, and that the corrupt one, having no type to be used as,
|
|
// cannot be selected by its directory name.
|
|
func TestUnlockerSelectSkipsCorruptUnlocker(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := newCorruptUnlockerVault(t)
|
|
instance, _ := newTestInstance(fs)
|
|
|
|
require.NoError(t, instance.UnlockerSelect("pgp-"+listTestGPGKeyID+"B"))
|
|
|
|
current, err := afero.ReadFile(fs,
|
|
filepath.Join(testVaultDir(listTestVaultName), "current-unlocker"))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, listTestUnlockerDirTwo, string(current))
|
|
|
|
err = instance.UnlockerSelect(listTestUnlockerDirOne)
|
|
require.ErrorIs(t, err, vault.ErrUnlockerNotFound)
|
|
}
|
|
|
|
// TestUnlockerRemoveWithCorruptUnlocker asserts that the second unlocker
|
|
// counts as the vault's last one, since the corrupt unlocker cannot unlock
|
|
// the vault, and that the corrupt one, removed by its directory name, does
|
|
// not. Either is removed once the user confirms.
|
|
func TestUnlockerRemoveWithCorruptUnlocker(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
unlockerID string
|
|
wantLast bool
|
|
wantEntries []string
|
|
}{
|
|
{
|
|
name: "the other unlocker",
|
|
unlockerID: "pgp-" + listTestGPGKeyID + "B",
|
|
wantLast: true,
|
|
wantEntries: []string{listTestUnlockerDirOne},
|
|
},
|
|
{
|
|
name: "the corrupt unlocker by its directory name",
|
|
unlockerID: listTestUnlockerDirOne,
|
|
wantEntries: []string{listTestUnlockerDirTwo},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := newCorruptUnlockerVault(t)
|
|
writeTestSecret(t, fs, testVaultDir(listTestVaultName))
|
|
|
|
instance, cmd := newTestInstance(fs)
|
|
|
|
found, err := instance.findUnlockerToRemove(tt.unlockerID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tt.wantLast, found.last)
|
|
|
|
instance.terminal = strings.NewReader("y\n")
|
|
require.NoError(t, instance.UnlockersRemove(tt.unlockerID, false, cmd))
|
|
|
|
assertDirEntries(t, fs,
|
|
filepath.Join(testVaultDir(listTestVaultName),
|
|
listTestUnlockersDirName),
|
|
tt.wantEntries...)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestUnlockerRemoveWithoutMetadata asserts that a partial unlocker
|
|
// directory, one with no metadata file, removed by its directory name from
|
|
// a vault with secrets, does not count as the vault's last unlocker, since
|
|
// it cannot unlock the vault, so the question says it is not. It is
|
|
// removed once the user confirms.
|
|
func TestUnlockerRemoveWithoutMetadata(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := newListTestVault(t, 2)
|
|
vaultDir := testVaultDir(listTestVaultName)
|
|
unlockersDir := filepath.Join(vaultDir, listTestUnlockersDirName)
|
|
|
|
require.NoError(t, fs.Remove(filepath.Join(
|
|
unlockersDir, listTestUnlockerDirOne, listTestMetadataFileName)))
|
|
writeTestSecret(t, fs, vaultDir)
|
|
|
|
instance, cmd := newTestInstance(fs)
|
|
|
|
found, err := instance.findUnlockerToRemove(listTestUnlockerDirOne)
|
|
require.NoError(t, err)
|
|
assert.False(t, found.last)
|
|
assert.Contains(t, found.question, "not the vault's last unlocker")
|
|
|
|
instance.terminal = strings.NewReader("y\n")
|
|
require.NoError(t, instance.UnlockersRemove(listTestUnlockerDirOne, false, cmd))
|
|
assertDirEntries(t, fs, unlockersDir, listTestUnlockerDirTwo)
|
|
}
|
|
|
|
// TestUnlockerRemoveWithUnreadableMetadata asserts that the only unlocker
|
|
// of a vault with secrets, removed by its directory name when its metadata
|
|
// file cannot be checked for or read, counts as the vault's last unlocker,
|
|
// so the question warns that it is: listing leaves it out, but it may
|
|
// still be the vault's only working unlocker. It is then removed. The
|
|
// state directory lock refuses the failing filesystem, so the test calls
|
|
// findUnlockerToRemove and removeUnlocker, which UnlockersRemove runs to
|
|
// make its checks and, once it holds the lock, to remove the unlocker.
|
|
func TestUnlockerRemoveWithUnreadableMetadata(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
vaultDir := testVaultDir(listTestVaultName)
|
|
unlockersDir := filepath.Join(vaultDir, listTestUnlockersDirName)
|
|
failingPath := filepath.Join(unlockersDir, listTestUnlockerDirOne,
|
|
listTestMetadataFileName)
|
|
|
|
tests := []struct {
|
|
name string
|
|
wrap func(base afero.Fs) afero.Fs
|
|
}{
|
|
{
|
|
name: "checking for the file fails",
|
|
wrap: func(base afero.Fs) afero.Fs {
|
|
return &metadataStatFailFs{Fs: base, uncheckablePath: failingPath}
|
|
},
|
|
},
|
|
{
|
|
name: "reading the file fails",
|
|
wrap: func(base afero.Fs) afero.Fs {
|
|
return &metadataReadFailFs{Fs: base, unreadablePath: failingPath}
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
base := newListTestVault(t, 1)
|
|
writeTestSecret(t, base, vaultDir)
|
|
|
|
instance, cmd := newTestInstance(tt.wrap(base))
|
|
|
|
found, err := instance.findUnlockerToRemove(listTestUnlockerDirOne)
|
|
require.NoError(t, err)
|
|
assert.True(t, found.last)
|
|
assert.Contains(t, found.question, "the last unlocker")
|
|
|
|
require.NoError(t,
|
|
instance.removeUnlocker(listTestUnlockerDirOne, found, cmd))
|
|
assertDirEntries(t, base, unlockersDir)
|
|
})
|
|
}
|
|
}
|