// Unlocker List Tests // // Tests for `secret unlocker list` behavior when the unlockers.d directory // 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. // // 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-". 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) }