check / check (push) Successful in 1m12s
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
286 lines
8.8 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|