2 Commits
Author SHA1 Message Date
sneak 7787609d2e Print an empty unlocker or vault list as [] in JSON (closes #148)
check / check (push) Canceled after 0s
`secret unlocker list --json` printed "unlockers": null for a vault with
no unlockers, and `secret vault list --json` printed "vaults": null when
vaults.d held no vaults. Both lists were nil slices, which encoding/json
writes as null. Each is now made as an empty slice before it is filled,
so an empty list prints as [].

vault.ListVaults therefore returns an empty slice instead of nil for an
empty vaults.d; its other callers only range over the result or check
its length.

Model: opus-5-5
2026-10-15 00:54:27 +00:00
clawbot acb4b9cb6a version promote --help says the current file is rewritten (closes #153)
check / check (push) Canceled after 0s
The help text for `secret version promote` and a comment in
PromoteVersion said promote updates a "current symlink". `current` is a
plain file holding the version name, and promote rewrites it; the README
already says so. A new test runs `secret version promote --help` and
checks the output does not mention a symlink and does name the current
file.

Model: opus-5-5
2026-10-15 02:31:47 +02:00
6 changed files with 104 additions and 7 deletions
+2 -1
View File
@@ -314,7 +314,8 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
return err
}
var unlockers []UnlockerInfo
// Not nil, so that a vault with no unlockers prints [] in JSON, not null
unlockers := make([]UnlockerInfo, 0, len(unlockerMetadata))
for _, unlockerID := range slices.Sorted(maps.Keys(unlockerMetadata)) {
metadata := unlockerMetadata[unlockerID]
+36 -2
View File
@@ -1,13 +1,14 @@
// Unlocker List Tests
//
// Tests for `secret unlocker list` behavior when an unlocker's metadata
// cannot be read or used:
// Tests for `secret unlocker list`:
//
// - 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.
// - TestUnlockersListEmptyJSON: a vault with no unlockers lists them as
// an empty JSON list.
//nolint:testpackage // white-box test of unexported internals
package cli
@@ -321,3 +322,36 @@ func TestUnlockersListSkipsUnreadableMetadata(t *testing.T) {
})
}
}
// TestUnlockersListEmptyJSON is a regression test for
// https://git.eeqj.de/sneak/secret/issues/148: listing the unlockers of a
// vault that has none, as after its last unlocker is removed, printed
// "unlockers": null in JSON form instead of an empty list.
func TestUnlockersListEmptyJSON(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
vaultDir := filepath.Join(listTestStateDir, "vaults.d", listTestVaultName)
require.NoError(t, afero.WriteFile(
fs, filepath.Join(listTestStateDir, "currentvault"),
[]byte(listTestVaultName), listTestFilePerm,
))
require.NoError(t, fs.MkdirAll(
filepath.Join(vaultDir, listTestUnlockersDirName), listTestDirPerm,
))
var buf bytes.Buffer
cmd := &cobra.Command{}
cmd.SetOut(&buf)
instance := &Instance{fs: fs, stateDir: listTestStateDir, cmd: cmd}
require.NoError(t, instance.UnlockersList(true))
var decoded map[string]any
require.NoError(t, json.Unmarshal(buf.Bytes(), &decoded))
assert.Equal(t, []any{}, decoded["unlockers"],
"no unlockers must be printed as [], not %s", buf.String())
}
+42
View File
@@ -0,0 +1,42 @@
package cli_test
import (
"bytes"
"encoding/json"
"path/filepath"
"testing"
"github.com/spf13/afero"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/cli"
"sneak.berlin/go/secret/internal/secret"
)
// TestVaultListEmptyJSON is a regression test for
// https://git.eeqj.de/sneak/secret/issues/148: listing the vaults when
// vaults.d holds none printed "vaults": null in JSON form instead of an
// empty list.
func TestVaultListEmptyJSON(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
require.NoError(t, fs.MkdirAll(
filepath.Join(testStateDir, "vaults.d"), secret.DirPerms,
))
var buf bytes.Buffer
cmd := &cobra.Command{}
cmd.SetOut(&buf)
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
require.NoError(t, c.ListVaults(cmd, true))
var decoded map[string]any
require.NoError(t, json.Unmarshal(buf.Bytes(), &decoded))
assert.Equal(t, []any{}, decoded["vaults"],
"no vaults must be printed as [], not %s", buf.String())
}
+2 -2
View File
@@ -64,7 +64,7 @@ func VersionCommands(cli *Instance) *cobra.Command {
promoteCmd := &cobra.Command{
Use: "promote <secret-name> <version>",
Short: "Promote a specific version to current",
Long: "Updates the current symlink to point to the specified " +
Long: "Rewrites the secret's current file to name the specified " +
"version. The versions' own times are left unchanged; secret " +
"list then gives the time of the promote as the secret's last " +
"update.",
@@ -293,7 +293,7 @@ func (cli *Instance) PromoteVersion(
version, vault.ErrVersionNotFound, secretName)
}
// Update the current symlink using the proper function
// Rewrite the secret's current file to name the version
err = secret.SetCurrentVersion(cli.fs, secretDir, version)
if err != nil {
return fmt.Errorf("failed to update current version: %w", err)
+19
View File
@@ -6,6 +6,8 @@
// - TestListVersionsNonExistentSecret: Tests error handling for missing secrets
// - TestPromoteVersionCommand: Tests `secret version promote` command
// - TestPromoteNonExistentVersion: Tests error handling for invalid promotion
// - TestPromoteHelpSaysCurrentFileIsRewritten: Tests `secret version promote
// --help` text
// - TestGetSecretWithVersion: Tests `secret get --version` flag functionality
// - TestGetSecretWritesBinaryValue: Tests `secret get` output of binary values
// - TestGetSecretReturnsWriteError: Tests `secret get` when stdout fails
@@ -270,6 +272,23 @@ func TestPromoteNonExistentVersion(t *testing.T) {
require.ErrorIs(t, err, vault.ErrVersionNotFound)
}
// TestPromoteHelpSaysCurrentFileIsRewritten is a regression test for
// https://git.eeqj.de/sneak/secret/issues/153: `secret version promote
// --help` said it updates a symlink, but promote rewrites the secret's
// `current` file, which is a plain file.
//
//nolint:paralleltest // executes the CLI in-process and sets the environment
func TestPromoteHelpSaysCurrentFileIsRewritten(t *testing.T) {
env := map[string]string{secret.EnvStateDir: t.TempDir()}
output, err := ExecuteCommandInProcess(
[]string{"version", "promote", "--help"}, "", env)
require.NoError(t, err)
assert.NotContains(t, output, "symlink")
assert.Contains(t, output, "current file")
}
func TestGetSecretWithVersion(t *testing.T) {
t.Parallel()
+3 -2
View File
@@ -140,8 +140,9 @@ func ListVaults(fs afero.Fs, stateDir string) ([]string, error) {
return nil, fmt.Errorf("failed to read vaults directory: %w", err)
}
// Extract vault names
var vaults []string
// Extract vault names. Not nil, so that no vaults prints [] in JSON,
// not null.
vaults := make([]string, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {