Files
secret/internal/cli/unlockers_list_test.go
T
clawbot af9ac6d979
check / check (push) Failing after 2s
Rename the Go module to sneak.berlin/go/secret (closes #43)
REPO_POLICIES.md requires the module root sneak.berlin/go/<name>.
go.mod, every import (rewritten with gofmt -r), the -X flags in
script/build and the examples in the pkg READMEs now use the new path.
go mod tidy lists go-humanize and fatih/color as direct requirements,
since internal/cli imports them.

This breaks anyone who fetched or imported git.eeqj.de/sneak/secret:
they must switch to sneak.berlin/go/secret, which resolves to this
repository.

Model: opus-5-5
2026-10-05 02:45:37 +02:00

324 lines
9.3 KiB
Go

// Unlocker List Tests
//
// Tests for `secret unlocker list` behavior when an unlocker's metadata
// cannot be read or used:
//
// - 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.
//nolint:testpackage // white-box test of unexported internals
package cli
import (
"bytes"
"encoding/json"
"errors"
"os"
"path/filepath"
"testing"
"time"
"github.com/spf13/afero"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
)
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 holding the unlockers.
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 fails every open of unlockers.d, as when the
// directory cannot be read.
type unlockersDirFailFs struct {
afero.Fs
}
//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 {
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
}
// 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, and
// metadata of an unknown type, are still listed, under the 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},
},
{
name: "unknown type",
metadata: `{"type": "unknown"}`,
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")
})
}
}