// 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. // - TestUnlockersListSkipsUnreadableMetadata: an unlocker whose metadata // file cannot be read is left out, and the other is still 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) } // errMetadataUnreadable is returned by the test filesystem in place of a // successful open of one unlocker's metadata file. var errMetadataUnreadable = errors.New("input/output error") // metadataReadFailFs fails every open of the file at unreadablePath. The // file still exists, so checking for it succeeds and only reading it fails. type metadataReadFailFs struct { afero.Fs unreadablePath string } //nolint:ireturn // afero.File is the interface required by afero.Fs func (f *metadataReadFailFs) Open(name string) (afero.File, error) { if name == f.unreadablePath { return nil, errMetadataUnreadable } //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) } // 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) } }) } } // TestUnlockersListSkipsUnreadableMetadata asserts that an unlocker whose // metadata file exists but cannot be read is left out of the listing, and // the other unlocker is still listed with its real ID. The unreadable one // sorts first, so finding the other's ID has to step past it as well. func TestUnlockersListSkipsUnreadableMetadata(t *testing.T) { t.Parallel() base := newListTestVault(t, 2) fs := &metadataReadFailFs{ Fs: base, unreadablePath: filepath.Join(listTestStateDir, "vaults.d", listTestVaultName, listTestUnlockersDirName, listTestUnlockerDirOne, listTestMetadataFileName), } unlockers := listUnlockersJSON(t, fs) require.Len(t, unlockers, 1, "only the unlocker with readable metadata may be listed") assert.Equal(t, "pgp-"+listTestGPGKeyID+"B", unlockers[0].ID, "the listed row must carry the real unlocker ID") }