check / check (push) Failing after 2s
Keychain and Secure Enclave unlocker IDs were the creation time to the minute plus the host name, and passphrase unlocker IDs the time to the minute, so two created within one minute shared an ID, and `unlocker select`, `unlocker remove` and the selection after `unlocker add` acted on the older one. Every unlocker's ID is now its directory name, unique in its vault. PGP unlocker IDs were `pgp-<fingerprint>`; the check that refuses a second PGP unlocker for one key now compares the fingerprint in the other unlockers' metadata. Model: opus-5-5
379 lines
12 KiB
Go
379 lines
12 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.
|
|
// - TestUnlockersListSkipsUnreadableMetadata: an unlocker whose metadata
|
|
// file cannot be checked for or 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"
|
|
"os"
|
|
"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, with a letter appended,
|
|
// in the PGP unlockers' metadata.
|
|
listTestGPGKeyID = "DEADBEEFDEADBEEF"
|
|
|
|
// listTestUnlockerDirOne and listTestUnlockerDirTwo are the unlocker
|
|
// directory names under unlockers.d, and so the unlockers' IDs.
|
|
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)
|
|
}
|
|
|
|
// errMetadataUncheckable is returned by the test filesystem in place of a
|
|
// successful check for one unlocker's metadata file.
|
|
var errMetadataUncheckable = errors.New("permission denied")
|
|
|
|
// metadataStatFailFs fails every check for whether the file at
|
|
// uncheckablePath exists, as when its unlocker directory cannot be entered.
|
|
type metadataStatFailFs struct {
|
|
afero.Fs
|
|
|
|
uncheckablePath string
|
|
}
|
|
|
|
func (f *metadataStatFailFs) Stat(name string) (os.FileInfo, error) {
|
|
if name == f.uncheckablePath {
|
|
return nil, errMetadataUncheckable
|
|
}
|
|
|
|
//nolint:wrapcheck // test double must return the wrapped Fs error as-is
|
|
return f.Fs.Stat(name)
|
|
}
|
|
|
|
// writePGPUnlocker writes a PGP unlocker directory named dirName, with
|
|
// metadata recording the GPG key ID 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, listTestUnlockerDirOne, 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, listTestUnlockerDirOne, unlockers[0].ID)
|
|
assert.Equal(t, listTestUnlockerDirTwo, 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 is
|
|
// still listed, under its directory name like any other. The healthy
|
|
// unlocker is listed with its real ID.
|
|
func TestUnlockersListToleratesCorruptMetadata(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
healthyID := listTestUnlockerDirOne
|
|
|
|
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, listTestUnlockerDirTwo},
|
|
},
|
|
{
|
|
name: "GPG key ID missing",
|
|
metadata: `{"type": "pgp"}`,
|
|
wantIDs: []string{healthyID, listTestUnlockerDirTwo},
|
|
},
|
|
}
|
|
|
|
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 cannot be checked for or cannot be read is left out of the
|
|
// listing, and the other unlocker is still listed with its real ID. The
|
|
// failing one sorts first, so finding the other's ID has to step past it
|
|
// as well.
|
|
func TestUnlockersListSkipsUnreadableMetadata(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
failingPath := filepath.Join(listTestStateDir, "vaults.d",
|
|
listTestVaultName, listTestUnlockersDirName,
|
|
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()
|
|
|
|
fs := tt.wrap(newListTestVault(t, 2))
|
|
|
|
unlockers := listUnlockersJSON(t, fs)
|
|
|
|
require.Len(t, unlockers, 1,
|
|
"only the unlocker with usable metadata may be listed")
|
|
assert.Equal(t, listTestUnlockerDirTwo, unlockers[0].ID,
|
|
"the listed row must carry the real unlocker ID")
|
|
})
|
|
}
|
|
}
|