//nolint:testpackage // white-box test of unexported internals package cli import ( "bytes" "os" "path/filepath" "strings" "testing" "github.com/awnumar/memguard" "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sneak.berlin/go/secret/internal/secret" "sneak.berlin/go/secret/internal/vault" ) // unknownTestGPGUserID is a GPG user ID that no key in the test keyring has. const unknownTestGPGUserID = "not-in-keyring@example.com" // The secret TestAddPGPUnlocker stores, then reads through the new unlocker. const ( addTestSecretName = "api-key" addTestSecretValue = "value" ) // TestAddPGPUnlocker adds a PGP unlocker for a throwaway GPG key to a vault // with a passphrase unlocker, getting the vault's long-term key from the // mnemonic or, with no mnemonic given, from the passphrase unlocker. It // then reads a secret with neither the mnemonic nor the passphrase given, so // through the new unlocker, which the add selects. // //nolint:paralleltest // t.Setenv (GNUPGHOME) forbids parallel tests func TestAddPGPUnlocker(t *testing.T) { newTestGPGKey(t) passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase)) t.Cleanup(passphrase.Destroy) tests := []struct { name string // mnemonic is the mnemonic given while the unlocker is added, or nil. mnemonic *memguard.LockedBuffer }{ {"long-term key from the mnemonic", testMnemonicBuffer(t)}, {"long-term key from the current unlocker", nil}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { fs := afero.NewMemMapFs() vlt, err := vault.CreateVault(fs, listTestStateDir, listTestVaultName, testMnemonicBuffer(t), nil) require.NoError(t, err) err = vlt.AddSecret(addTestSecretName, memguard.NewBufferFromBytes([]byte(addTestSecretValue)), false) require.NoError(t, err) _, err = vlt.CreatePassphraseUnlocker( memguard.NewBufferFromBytes([]byte(testPassphrase))) require.NoError(t, err) instance, cmd := newTestInstance(fs) instance.Mnemonic = test.mnemonic instance.UnlockPassphrase = passphrase cmd.Flags().String("keyid", unreadableTestGPGUserID, "") require.NoError(t, instance.UnlockersAdd(unlockerTypePGP, cmd)) reopened := vault.NewVault(fs, listTestStateDir, listTestVaultName) current, err := reopened.GetCurrentUnlocker() require.NoError(t, err) assert.Equal(t, unlockerTypePGP, current.GetType()) value, err := reopened.GetSecret(addTestSecretName) require.NoError(t, err) defer value.Destroy() assert.Equal(t, addTestSecretValue, value.String()) }) } } // TestAddPGPUnlockerDefaultKey is a regression test for // https://git.eeqj.de/sneak/secret/issues/141: with default-key set in // gpg.conf and no --keyid, `secret unlocker add pgp` uses that key. // gpg.conf names the key by its user ID, and the printed key ID must be that // user ID. The first secret key, which is used when gpg.conf sets no // default-key, prints as a hex key ID. func TestAddPGPUnlockerDefaultKey(t *testing.T) { newTestGPGKey(t) // The key comes from neither --keyid nor the environment. t.Setenv(secret.EnvGPGKeyID, "") gpgConf := filepath.Join(os.Getenv("GNUPGHOME"), "gpg.conf") //nolint:gosec // G703: GNUPGHOME is the directory newTestGPGKey made require.NoError(t, os.WriteFile(gpgConf, []byte("default-key "+unreadableTestGPGUserID+"\n"), 0o600)) fs := afero.NewMemMapFs() _, err := vault.CreateVault(fs, listTestStateDir, listTestVaultName, testMnemonicBuffer(t), nil) require.NoError(t, err) instance, cmd := newTestInstance(fs) instance.Mnemonic = testMnemonicBuffer(t) var output bytes.Buffer cmd.SetOut(&output) require.NoError(t, instance.UnlockersAdd(unlockerTypePGP, cmd)) assert.Contains(t, output.String(), "Using default GPG key: "+unreadableTestGPGUserID+"\n") } // TestParseDefaultGPGKey is a regression test for // https://git.eeqj.de/sneak/secret/issues/141: gpgconf prints the // default-key value with a leading `"` and percent-escaped, and the key ID // is that value decoded. func TestParseDefaultGPGKey(t *testing.T) { t.Parallel() // gpgconfLine is the default-key line of `gpgconf --list-options gpg` // up to the value, its last field. const gpgconfLine = "default-key:0:0:use NAME as default secret key:" + "1:1:NAME:::" tests := []struct { name string value string want string }{ { "fingerprint", `"56163D7F3FAFF709608855BEC1A360216D007F41`, "56163D7F3FAFF709608855BEC1A360216D007F41", }, { "user ID with a comma", `"Doe%2c Alice `, "Doe, Alice ", }, {"no default-key in gpg.conf", "", ""}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { t.Parallel() keyID, err := parseDefaultGPGKey(gpgconfLine + test.value + "\n") require.NoError(t, err) assert.Equal(t, test.want, keyID) }) } } // TestAddPGPUnlockerUnknownKey asserts that adding a PGP unlocker for a key // the keyring does not hold fails at looking up the key's fingerprint and // leaves no new unlocker directory. The error must come from the lookup: a // lookup moved after anything is written would also come after getting the // vault's long-term key, which fails first here: this vault's unlockers hold // no keys. // //nolint:paralleltest // t.Setenv (GNUPGHOME) forbids parallel tests func TestAddPGPUnlockerUnknownKey(t *testing.T) { newTestGPGKey(t) base := newListTestVault(t, 1) instance, cmd := newTestInstance(base) cmd.Flags().String("keyid", unknownTestGPGUserID, "") err := instance.addPGPUnlocker(cmd) require.ErrorIs(t, err, secret.ErrGPGKeyNotFound) assertDirEntries(t, base, filepath.Join(testVaultDir(listTestVaultName), listTestUnlockersDirName), listTestUnlockerDirOne) } // TestUnlockerAddKnowsOnlyPassphraseAndPGP is a regression test for // https://git.eeqj.de/sneak/secret/issues/132: `secret unlocker add` takes // only passphrase and pgp, on every platform. keychain and secure-enclave // fail as unknown types, and neither --help nor shell completion offers // them. // //nolint:paralleltest // executes the CLI in-process and sets the environment func TestUnlockerAddKnowsOnlyPassphraseAndPGP(t *testing.T) { env := map[string]string{secret.EnvStateDir: t.TempDir()} removedTypes := []string{"keychain", "secure-enclave"} for _, unlockerType := range removedTypes { _, err := ExecuteCommandInProcess( []string{"unlocker", "add", unlockerType}, "", env) require.ErrorIs(t, err, errInvalidUnlockerType) instance, cmd := newTestInstance(newListTestVault(t, 1)) require.ErrorIs(t, instance.UnlockersAdd(unlockerType, cmd), errInvalidUnlockerType) } // --help prints the command's Long text, and shell completion offers // its ValidArgs. addCmd := newUnlockerAddCmd() help := addCmd.Long completions := strings.Join(addCmd.ValidArgs, " ") for _, offered := range []string{help, completions} { assert.Contains(t, offered, unlockerTypePassphrase) assert.Contains(t, offered, unlockerTypePGP) for _, unlockerType := range removedTypes { assert.NotContains(t, offered, unlockerType) } } }