check / check (push) Successful in 55s
Each command that changes the state directory holds one lock: flock(2) on `lock` in the state directory, dropped by the kernel if the process dies, or a process-wide mutex on the in-memory test filesystem. It covers the state directory, not each vault, because `currentvault`, `vault create` and cross-vault moves span vaults, and a lock file in a vault would be deleted by `vault remove` under a waiting command. Serializing changes across vaults costs a command-line tool nothing. Files go through `secret.WriteFileAtomic`; versions, new secrets and cross-vault copies are built in a temporary directory and renamed into place; removals rename out of the way first. Model: opus-5-5
151 lines
3.9 KiB
Go
151 lines
3.9 KiB
Go
//nolint:testpackage // sets the unexported fields of Instance
|
|
package cli
|
|
|
|
import (
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.eeqj.de/sneak/secret/internal/secret"
|
|
"git.eeqj.de/sneak/secret/internal/vault"
|
|
"github.com/spf13/afero"
|
|
"github.com/spf13/cobra"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// addAtOnce runs one add of the secret name per value, all at once, and
|
|
// returns their errors.
|
|
func addAtOnce(
|
|
fs afero.Fs, stateDir, name string, force bool, values []string,
|
|
) []error {
|
|
errs := make(chan error, len(values))
|
|
|
|
for _, value := range values {
|
|
go func() {
|
|
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
|
cli.cmd = &cobra.Command{}
|
|
cli.cmd.SetIn(strings.NewReader(value))
|
|
|
|
errs <- cli.AddSecret(name, force)
|
|
}()
|
|
}
|
|
|
|
results := make([]error, 0, len(values))
|
|
for range values {
|
|
results = append(results, <-errs)
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
// numbered returns count distinct values starting with prefix.
|
|
func numbered(prefix string, count int) []string {
|
|
values := make([]string, 0, count)
|
|
for i := range count {
|
|
values = append(values, prefix+"-"+strconv.Itoa(i))
|
|
}
|
|
|
|
return values
|
|
}
|
|
|
|
// TestConcurrentAddsKeepEveryVersion runs adds of one secret at once, on
|
|
// the in-memory and on the real filesystem. Without the state directory
|
|
// lock, adds of a new secret all find it absent and replace each other, and
|
|
// forced adds read the same highest version number and overwrite each
|
|
// other's version. With it they behave as if run one after another.
|
|
//
|
|
//nolint:paralleltest // t.Setenv forbids parallel subtests
|
|
func TestConcurrentAddsKeepEveryVersion(t *testing.T) {
|
|
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
|
|
|
const adds = 8
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
fs afero.Fs
|
|
stateDir string
|
|
}{
|
|
{"memory", afero.NewMemMapFs(), testStateDir},
|
|
{"real", afero.NewOsFs(), t.TempDir()},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
_, err := vault.CreateVault(tc.fs, tc.stateDir, "default")
|
|
require.NoError(t, err)
|
|
|
|
// One add creates the secret; the others find that it exists
|
|
created := 0
|
|
|
|
for _, err := range addAtOnce(tc.fs, tc.stateDir, "shared", false,
|
|
numbered("create", adds)) {
|
|
if err == nil {
|
|
created++
|
|
} else {
|
|
require.ErrorIs(t, err, vault.ErrSecretExists)
|
|
}
|
|
}
|
|
|
|
require.Equal(t, 1, created, "exactly one add creates the secret")
|
|
|
|
// Every forced add stores a version of its own
|
|
for _, err := range addAtOnce(tc.fs, tc.stateDir, "shared", true,
|
|
numbered("force", adds)) {
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
vlt, err := vault.GetCurrentVault(tc.fs, tc.stateDir)
|
|
require.NoError(t, err)
|
|
|
|
vaultDir, err := vlt.GetDirectory()
|
|
require.NoError(t, err)
|
|
|
|
versions, err := secret.ListVersions(tc.fs,
|
|
filepath.Join(vaultDir, "secrets.d", "shared"))
|
|
require.NoError(t, err)
|
|
require.Len(t, versions, adds+1, "one version per successful add")
|
|
|
|
values := make(map[string]bool, len(versions))
|
|
|
|
for _, version := range versions {
|
|
value, err := vlt.GetSecretVersion("shared", version)
|
|
require.NoError(t, err)
|
|
|
|
values[string(value)] = true
|
|
}
|
|
|
|
assert.Len(t, values, adds+1, "every add stored its own value")
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestFailedCommandReleasesLock checks that a command failing after it
|
|
// took the state directory lock leaves the lock free for the next command.
|
|
func TestFailedCommandReleasesLock(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
cli := NewCLIInstanceWithStateDir(fs, testStateDir)
|
|
|
|
// Fails once it holds the lock: there is no current vault
|
|
err := cli.RemoveSecret(&cobra.Command{}, "missing", false)
|
|
require.Error(t, err)
|
|
|
|
taken := make(chan func(), 1)
|
|
|
|
go func() {
|
|
release, err := vault.LockStateDir(fs, testStateDir)
|
|
if assert.NoError(t, err) {
|
|
taken <- release
|
|
}
|
|
}()
|
|
|
|
select {
|
|
case release := <-taken:
|
|
release()
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("the failed command left the state directory locked")
|
|
}
|
|
}
|