diff --git a/README.md b/README.md index 3eaa1c4..63d0120 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,12 @@ Moves or renames a secret within the current vault. Lists all versions of a secret showing creation time, status, and validity period. +`STATUS` is `current` for the secret's current version, `expired` for another +version whose `NOT_AFTER` has passed, and `inactive` for any other version. +Adding a version sets `NOT_AFTER` on the version that was current; +`secret version promote` sets no times. A version that is not current therefore +shows `inactive` when no version was added while it was current. + #### `secret version promote ` Promotes a specific version to current by rewriting the secret's `current` file diff --git a/internal/cli/version.go b/internal/cli/version.go index a4b8a8c..6d292e3 100644 --- a/internal/cli/version.go +++ b/internal/cli/version.go @@ -229,9 +229,13 @@ func printVersionRow( } // Determine status - status := "expired" - if version == currentVersion { + status := "inactive" + + switch { + case version == currentVersion: status = "current" + case sv.Metadata.NotAfter != nil && sv.Metadata.NotAfter.Before(time.Now()): + status = "expired" } // Format timestamps diff --git a/internal/cli/version_test.go b/internal/cli/version_test.go index f50d64b..ddc2f29 100644 --- a/internal/cli/version_test.go +++ b/internal/cli/version_test.go @@ -5,6 +5,8 @@ // - TestListVersionsCommand: Tests `secret version list` command output // - TestListVersionsNonExistentSecret: Tests error handling for missing secrets // - TestPromoteVersionCommand: Tests `secret version promote` command +// - TestListVersionsStatusAfterPromote: Tests each version's status after +// promoting an older version // - TestPromoteNonExistentVersion: Tests error handling for invalid promotion // - TestPromoteHelpSaysCurrentFileIsRewritten: Tests `secret version promote // --help` text @@ -244,6 +246,66 @@ func TestPromoteVersionCommand(t *testing.T) { assert.Equal(t, []byte("version-1"), promoted.Bytes()) } +func TestListVersionsStatusAfterPromote(t *testing.T) { + t.Parallel() + + fs := afero.NewMemMapFs() + cli := NewCLIInstanceWithStateDir(fs, testStateDir) + cli.Mnemonic = testMnemonicBuffer(t) + + setupTestVault(t, fs) + + vlt, err := vault.GetCurrentVault(fs, testStateDir) + require.NoError(t, err) + + vlt.Mnemonic = cli.Mnemonic + + addTestSecret(t, vlt, []byte("version-1"), false) + addTestSecret(t, vlt, []byte("version-2"), true) + addTestSecret(t, vlt, []byte("version-3"), true) + + vaultDir, err := vlt.GetDirectory() + require.NoError(t, err) + + // Newest first + versions, err := secret.ListVersions(fs, + filepath.Join(vaultDir, "secrets.d", "test%secret")) + require.NoError(t, err) + require.Len(t, versions, 3) + + newest, middle, oldest := versions[0], versions[1], versions[2] + + cmd := newRootCmd() + + var buf bytes.Buffer + cmd.SetOut(&buf) + cmd.SetErr(&buf) + + err = cli.PromoteVersion(cmd, "test/secret", oldest) + require.NoError(t, err) + + buf.Reset() + + err = cli.ListVersions(cmd, "test/secret") + require.NoError(t, err) + + // STATUS is the fourth field of a row, since CREATED is a date and a time + statuses := map[string]string{} + + for line := range strings.SplitSeq(buf.String(), "\n") { + fields := strings.Fields(line) + if len(fields) > 3 { + statuses[fields[0]] = fields[3] + } + } + + assert.Equal(t, "current", statuses[oldest]) + // Its NOT_AFTER was set when the newest version was added + assert.Equal(t, "expired", statuses[middle]) + // It has no NOT_AFTER + assert.Equal(t, "inactive", statuses[newest]) +} + func TestPromoteNonExistentVersion(t *testing.T) { t.Parallel() diff --git a/internal/secret/version.go b/internal/secret/version.go index 16e4159..5f6604c 100644 --- a/internal/secret/version.go +++ b/internal/secret/version.go @@ -32,7 +32,8 @@ type VersionMetadata struct { ID string `json:"id"` // ULID CreatedAt *time.Time `json:"createdAt,omitempty"` // When version was created NotBefore *time.Time `json:"notBefore,omitempty"` // When this version becomes active - NotAfter *time.Time `json:"notAfter,omitempty"` // Expiry (nil = current) + // NotAfter is set when another version is added while this one is current + NotAfter *time.Time `json:"notAfter,omitempty"` } // Version represents a version of a secret