Files
secret/internal/cli/unlockers_list_test.go
T
clawbot c43eb26127
check / check (push) Successful in 1m12s
Keep unlocker list working when unlocker metadata is corrupt (closes #42)
PGPUnlocker.GetID() panicked when its metadata could not be read or
parsed, which took down `secret unlocker list` for every unlocker. It
now warns with the unlocker's directory and returns `pgp-unknown`;
metadata with an empty GPG key ID counts as corrupt too.
ListUnlockers now skips, with a warning, an unlocker whose metadata
file is unreadable or not JSON, as it already did for a missing one.

This is the first half of the issue only. Passing the mnemonic in
memory moved to #60.

Model: opus-5-5
2026-10-03 12:17:04 +00:00

286 lines
8.8 KiB
Go

// Unlocker List Tests
//
// Tests for `secret unlocker list` behavior when the unlockers.d directory,
// or an unlocker's metadata in it, cannot be read while the listing is
// being rendered:
//
// - TestUnlockersListSkipsUnreadableUnlockersDir: an unreadable
// unlockers.d yields no rows rather than rows bearing synthesized IDs.
// - TestUnlockersListSkipsOnlyUnreadableEntries: a readable entry is
// still listed, with its real ID and its current-unlocker marker,
// when a later entry's scan fails.
// - TestUnlockersListToleratesCorruptMetadata: one unlocker's corrupt
// metadata does not stop the others from being listed.
//
// The listing resolves each unlocker's real ID by rescanning unlockers.d
// after the vault has already enumerated it. If that rescan fails the ID
// is unknowable, so the entry must be skipped: a synthesized ID matches
// no `unlocker remove` or `unlocker select` argument and would also
// suppress the current-unlocker marker.
//nolint:testpackage // white-box test of unexported internals
package cli
import (
"bytes"
"encoding/json"
"errors"
"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 (
// listTestStateDir is the state directory of the synthetic vault used
// by the unlocker listing tests.
listTestStateDir = "/state"
// listTestVaultName is the name of that synthetic vault.
listTestVaultName = "default"
// listTestGPGKeyID is the GPG key ID recorded in the readable PGP
// unlocker's metadata. The unlocker's real ID is derived from it, and
// differs from the timestamp-derived fallback ID.
listTestGPGKeyID = "DEADBEEFDEADBEEF"
// listTestUnlockerDirOne and listTestUnlockerDirTwo are the unlocker
// directory names under unlockers.d.
listTestUnlockerDirOne = "host-pgp-2026-08-09"
listTestUnlockerDirTwo = "host-pgp-2026-08-10"
// listTestUnlockersDirName is the directory the listing rescans to
// resolve unlocker IDs.
listTestUnlockersDirName = "unlockers.d"
// listTestMetadataFileName is the per-unlocker metadata file name.
listTestMetadataFileName = "unlocker-metadata.json"
// listTestDirPerm and listTestFilePerm are the fixture permissions.
listTestDirPerm = 0o700
listTestFilePerm = 0o600
)
// errUnlockersDirUnreadable is returned by the test filesystem in place of
// a successful open of unlockers.d.
var errUnlockersDirUnreadable = errors.New("permission denied")
// unlockersDirFailFs makes unlockers.d unreadable once it has been opened
// successfully openBudget times. This reproduces the directory becoming
// unreadable (permission change, partially restored backup, EIO) between
// the vault's own enumeration and the per-entry rescan that resolves
// unlocker IDs.
type unlockersDirFailFs struct {
afero.Fs
openBudget int
opens int
}
//nolint:ireturn // afero.File is the interface required by afero.Fs
func (f *unlockersDirFailFs) Open(name string) (afero.File, error) {
if filepath.Base(name) == listTestUnlockersDirName {
f.opens++
if f.opens > f.openBudget {
return nil, errUnlockersDirUnreadable
}
}
//nolint:wrapcheck // test double must return the wrapped Fs error as-is
return f.Fs.Open(name)
}
// writePGPUnlocker writes a PGP unlocker directory with metadata that
// yields the real ID "pgp-<keyID>".
func writePGPUnlocker(
t *testing.T, fs afero.Fs, unlockersDir, dirName string,
createdAt time.Time, keyID string,
) {
t.Helper()
metadata := secret.PGPUnlockerMetadata{
UnlockerMetadata: secret.UnlockerMetadata{
Type: unlockerTypePGP,
CreatedAt: createdAt,
},
GPGKeyID: keyID,
}
encoded, err := json.Marshal(metadata)
require.NoError(t, err)
dir := filepath.Join(unlockersDir, dirName)
require.NoError(t, fs.MkdirAll(dir, listTestDirPerm))
require.NoError(t, afero.WriteFile(
fs, filepath.Join(dir, listTestMetadataFileName), encoded,
listTestFilePerm,
))
}
// newListTestVault builds a synthetic vault on a MemMapFs containing the
// given number of PGP unlockers, with the first one selected as current.
func newListTestVault(t *testing.T, unlockerCount int) *afero.MemMapFs {
t.Helper()
base := &afero.MemMapFs{}
vaultDir := filepath.Join(listTestStateDir, "vaults.d", listTestVaultName)
unlockersDir := filepath.Join(vaultDir, listTestUnlockersDirName)
require.NoError(t, afero.WriteFile(
base, filepath.Join(listTestStateDir, "currentvault"),
[]byte(listTestVaultName), listTestFilePerm,
))
names := []string{listTestUnlockerDirOne, listTestUnlockerDirTwo}
names = names[:unlockerCount]
for i, name := range names {
writePGPUnlocker(t, base, unlockersDir, name,
time.Date(2026, time.August, 9+i, 12, 30, 0, 0, time.UTC),
listTestGPGKeyID+string(rune('A'+i)),
)
}
require.NoError(t, afero.WriteFile(
base, filepath.Join(vaultDir, "current-unlocker"),
[]byte(names[0]), listTestFilePerm,
))
return base
}
// listUnlockersJSON runs UnlockersList in JSON mode against the given
// filesystem and decodes the emitted unlocker rows.
func listUnlockersJSON(t *testing.T, fs afero.Fs) []UnlockerInfo {
t.Helper()
var buf bytes.Buffer
cmd := &cobra.Command{}
cmd.SetOut(&buf)
cmd.SetErr(&buf)
instance := &Instance{fs: fs, stateDir: listTestStateDir, cmd: cmd}
require.NoError(t, instance.UnlockersList(true))
var decoded struct {
Unlockers []UnlockerInfo `json:"unlockers"`
}
require.NoError(t, json.Unmarshal(buf.Bytes(), &decoded))
return decoded.Unlockers
}
// TestUnlockersListSkipsUnreadableUnlockersDir asserts that an unlockers.d
// which becomes unreadable after the vault enumerated it produces no rows,
// rather than rows carrying fabricated fallback IDs.
func TestUnlockersListSkipsUnreadableUnlockersDir(t *testing.T) {
t.Parallel()
base := newListTestVault(t, 1)
// Budget of one: the vault's own ListUnlockers scan succeeds, the
// per-entry rescan that resolves the ID fails.
fs := &unlockersDirFailFs{Fs: base, openBudget: 1}
unlockers := listUnlockersJSON(t, fs)
assert.Empty(t, unlockers,
"an unreadable unlockers.d must yield no rows, not fabricated IDs")
}
// TestUnlockersListSkipsOnlyUnreadableEntries asserts that a readable
// entry survives with its real ID and current-unlocker marker when a later
// entry's rescan fails.
func TestUnlockersListSkipsOnlyUnreadableEntries(t *testing.T) {
t.Parallel()
base := newListTestVault(t, 2)
// Budget of two: ListUnlockers plus the first entry's rescan succeed,
// the second entry's rescan fails.
fs := &unlockersDirFailFs{Fs: base, openBudget: 2}
unlockers := listUnlockersJSON(t, fs)
require.Len(t, unlockers, 1,
"only the entry whose directory was readable may be listed")
assert.Equal(t, "pgp-"+listTestGPGKeyID+"A", unlockers[0].ID,
"the surviving row must carry the real unlocker ID")
assert.True(t, unlockers[0].IsCurrent,
"the current-unlocker marker must survive the skip")
}
// TestUnlockersListReadableEntriesAreListed is the control case: with a
// fully readable unlockers.d every entry is listed with its real ID.
func TestUnlockersListReadableEntriesAreListed(t *testing.T) {
t.Parallel()
base := newListTestVault(t, 2)
unlockers := listUnlockersJSON(t, base)
require.Len(t, unlockers, 2)
assert.Equal(t, "pgp-"+listTestGPGKeyID+"A", unlockers[0].ID)
assert.Equal(t, "pgp-"+listTestGPGKeyID+"B", unlockers[1].ID)
assert.True(t, unlockers[0].IsCurrent)
assert.False(t, unlockers[1].IsCurrent)
}
// TestUnlockersListToleratesCorruptMetadata asserts that one unlocker with
// corrupt metadata does not stop the listing. Metadata that is not JSON
// leaves that unlocker out; PGP metadata without a usable GPG key ID lists
// it as "pgp-unknown". The healthy unlocker is listed with its real ID.
func TestUnlockersListToleratesCorruptMetadata(t *testing.T) {
t.Parallel()
healthyID := "pgp-" + listTestGPGKeyID + "A"
tests := []struct {
name string
metadata string
wantIDs []string
}{
{
name: "not JSON",
metadata: "not json",
wantIDs: []string{healthyID},
},
{
name: "GPG key ID of the wrong type",
metadata: `{"type": "pgp", "gpgKeyId": 42}`,
wantIDs: []string{healthyID, "pgp-unknown"},
},
{
name: "GPG key ID missing",
metadata: `{"type": "pgp"}`,
wantIDs: []string{healthyID, "pgp-unknown"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
fs := newListTestVault(t, 2)
metadataPath := filepath.Join(listTestStateDir, "vaults.d",
listTestVaultName, listTestUnlockersDirName,
listTestUnlockerDirTwo, listTestMetadataFileName)
require.NoError(t, afero.WriteFile(
fs, metadataPath, []byte(tt.metadata), listTestFilePerm,
))
unlockers := listUnlockersJSON(t, fs)
require.Len(t, unlockers, len(tt.wantIDs))
for i, wantID := range tt.wantIDs {
assert.Equal(t, wantID, unlockers[i].ID)
}
})
}
}