package cli_test import ( "bytes" "encoding/json" "os" "os/exec" "path/filepath" "testing" "time" "github.com/awnumar/memguard" "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" "sneak.berlin/go/secret/internal/vault" ) const ( // listClockStep is how long TestListShowsWhenSecretWasWritten waits // between its steps, so that a time read in one step cannot be the // time read in the next. listClockStep = 200 * time.Millisecond // fileTimeSlack allows for the clock the kernel stamps files with, // which advances only every few milliseconds: a file's modification // time can be slightly earlier than a time.Now() read before the file // was written. fileTimeSlack = 100 * time.Millisecond ) // TestListShowsWhenSecretWasWritten is a regression test for // https://git.eeqj.de/sneak/secret/issues/142, where `secret list` gave // the time of the listing as every secret's creation and last-update time. // Two listings with a clock step between must be the same, and give the // time of the add; after a forced add, the creation time is unchanged and // the last update is the time of that add. It runs on a real filesystem, // where writing a file into a directory also changes the directory's time. func TestListShowsWhenSecretWasWritten(t *testing.T) { t.Parallel() fs := afero.NewOsFs() stateDir := t.TempDir() vlt, err := vault.CreateVault(fs, stateDir, "default", testMnemonicBuffer(t), nil) require.NoError(t, err) vlt.Mnemonic = testMnemonicBuffer(t) c := cli.NewCLIInstanceWithStateDir(fs, stateDir) beforeAdd := time.Now() err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("one")), false) require.NoError(t, err) afterAdd := time.Now() firstListing := listSecrets(t, c, true) time.Sleep(listClockStep) require.Equal(t, firstListing, listSecrets(t, c, true)) added := listedTimes(t, firstListing) assert.WithinRange(t, added.CreatedAt, beforeAdd.Add(-fileTimeSlack), afterAdd) assert.WithinRange(t, added.UpdatedAt, beforeAdd.Add(-fileTimeSlack), afterAdd) time.Sleep(listClockStep) beforeUpdate := time.Now() err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("two")), true) require.NoError(t, err) afterUpdate := time.Now() updated := listedTimes(t, listSecrets(t, c, true)) assert.True(t, added.CreatedAt.Equal(updated.CreatedAt), "created %s, then %s", added.CreatedAt, updated.CreatedAt) assert.WithinRange(t, updated.UpdatedAt, beforeUpdate.Add(-fileTimeSlack), afterUpdate) // The table's LAST UPDATED column gives the same time, to the minute assert.Contains(t, listSecrets(t, c, false), updated.UpdatedAt.Format("2006-01-02 15:04")) } // TestMovedSecretKeepsItsTimes checks that a secret moved to another vault // is listed there with the times it was listed with before the move, and // not with the time of the move. func TestMovedSecretKeepsItsTimes(t *testing.T) { t.Parallel() fs := afero.NewOsFs() stateDir := t.TempDir() _, err := vault.CreateVault(fs, stateDir, "other", testMnemonicBuffer(t), nil) require.NoError(t, err) // "default" is created last, so it is the current vault vlt, err := vault.CreateVault(fs, stateDir, "default", testMnemonicBuffer(t), nil) require.NoError(t, err) vlt.Mnemonic = testMnemonicBuffer(t) // Two versions written apart, so that the creation and the last update // are different times err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("one")), false) require.NoError(t, err) time.Sleep(listClockStep) err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("two")), true) require.NoError(t, err) c := cli.NewCLIInstanceWithStateDir(fs, stateDir) c.Mnemonic = testMnemonicBuffer(t) beforeMove := listedTimes(t, listSecrets(t, c, true)) time.Sleep(listClockStep) require.NoError(t, c.MoveSecret(&cobra.Command{}, "default:x", "other", false)) require.NoError(t, vault.SelectVault(fs, stateDir, "other")) afterMove := listedTimes(t, listSecrets(t, c, true)) assert.True(t, beforeMove.CreatedAt.Equal(afterMove.CreatedAt), "created %s, then %s", beforeMove.CreatedAt, afterMove.CreatedAt) assert.True(t, beforeMove.UpdatedAt.Equal(afterMove.UpdatedAt), "updated %s, then %s", beforeMove.UpdatedAt, afterMove.UpdatedAt) } // TestListedTimesAreUTC is a regression test for // https://git.eeqj.de/sneak/secret/issues/169, where times were printed // in the local time zone. It runs `secret list` on the built binary with // TZ set to New York, where the secret's last update, 03:04:05 UTC on 2 // January, is 22:04:05 the day before. func TestListedTimesAreUTC(t *testing.T) { t.Parallel() stateDir := t.TempDir() vlt, err := vault.CreateVault( afero.NewOsFs(), stateDir, "default", testMnemonicBuffer(t), nil) require.NoError(t, err) vlt.Mnemonic = testMnemonicBuffer(t) err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("one")), false) require.NoError(t, err) updated := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) current := filepath.Join(stateDir, "vaults.d", "default", "secrets.d", "x", "current") require.NoError(t, os.Chtimes(current, updated, updated)) tests := []struct { flags []string want string }{ {flags: nil, want: "2026-01-02 03:04"}, { flags: []string{"--json"}, want: `"updated_at": "2026-01-02T03:04:05Z"`, }, } for _, tt := range tests { args := append([]string{"list"}, tt.flags...) //nolint:gosec // G204: test executes the freshly built secret binary cmd := exec.CommandContext(t.Context(), secretBinaryPath(t), args...) cmd.Env = []string{ secret.EnvStateDir + "=" + stateDir, "TZ=America/New_York", "PATH=" + os.Getenv("PATH"), } output, err := cmd.CombinedOutput() require.NoError(t, err, "%v: %s", args, output) assert.Contains(t, string(output), tt.want, "%v", args) } } // listSecrets returns the output of `secret list`, or of // `secret list --json` when jsonOutput is set. func listSecrets(t *testing.T, c *cli.Instance, jsonOutput bool) string { t.Helper() var out bytes.Buffer cmd := &cobra.Command{} cmd.SetOut(&out) require.NoError(t, c.ListSecrets(cmd, jsonOutput, false, "")) return out.String() } // listedTimes returns the times that the output of `secret list --json` // gives for the one secret in the vault. func listedTimes(t *testing.T, listing string) secret.Metadata { t.Helper() var parsed struct { Secrets []map[string]string `json:"secrets"` } require.NoError(t, json.Unmarshal([]byte(listing), &parsed)) require.Len(t, parsed.Secrets, 1) createdAt, err := time.Parse(time.RFC3339Nano, parsed.Secrets[0]["created_at"]) require.NoError(t, err) updatedAt, err := time.Parse(time.RFC3339Nano, parsed.Secrets[0]["updated_at"]) require.NoError(t, err) return secret.Metadata{CreatedAt: createdAt, UpdatedAt: updatedAt} }