Make the tests fast under the race detector (closes #120)
check / check (push) Successful in 1m25s

Deriving keys from passphrases with scrypt, slow on purpose, took most
of the test time under -race. secret.ScryptWorkFactor, when not zero,
replaces age's work factor when a passphrase encrypts; the tests of
internal/secret, internal/vault and internal/cli set it to 1 in
TestMain, and the program never sets it. TestGetCommandOutputsToStdout
checks that the built binary's passphrase unlocker names age's 18.

TestRemovalAsksWithoutHoldingLock and TestFailedCommandReleasesLock
time the in-memory lock all tests share, so they no longer run in
parallel. TestConcurrentAddsKeepEveryVersion and
TestGetCommandOutputsToStdout time nothing and now do.

The script/cibuild comment no longer says tests are skipped without
its memlock ulimit.

Model: opus-5-5
This commit was merged in pull request #123.
This commit is contained in:
2026-10-06 07:02:37 +02:00
parent df47ab386c
commit 4ff0d20c10
9 changed files with 80 additions and 14 deletions
+16
View File
@@ -18,6 +18,22 @@ https://git.eeqj.de/sneak/secret/milestone/12
# Completed Steps
- 2026-10-06: The tests run quickly with the race detector on
(https://git.eeqj.de/sneak/secret/issues/120). Most of their time went to
deriving keys from passphrases with scrypt, which is slow on purpose. The new
`secret.ScryptWorkFactor`, when not zero, replaces age's scrypt work factor
when a passphrase encrypts; the tests of `internal/secret`, `internal/vault`
and `internal/cli` set it to 1 before any test runs, and the program never
sets it; `TestGetCommandOutputsToStdout` checks that the passphrase unlocker
the built binary's `secret init` writes names age's work factor, 18.
`TestRemovalAsksWithoutHoldingLock` and `TestFailedCommandReleasesLock` no
longer run in parallel with other tests: each waits at most 10 seconds for the
in-memory lock that every test in the package shares, and other tests'
commands held it longer. `TestConcurrentAddsKeepEveryVersion`, which times
nothing, and `TestGetCommandOutputsToStdout`, which no longer sets an
environment variable its commands do not read, now run in parallel. The
`script/cibuild` comment no longer says that tests are skipped without its
memlock ulimit.
- 2026-10-05: No test stores a secret larger than 1 MiB
(https://git.eeqj.de/sneak/secret/issues/52). The size tests for `secret add`,
`secret import` and the stdin buffer no longer try 2 MB, 10 MB, 99 MB, 100 MB
+2 -2
View File
@@ -353,9 +353,9 @@ func TestRemovalWithoutTerminalFailsAtOnce(t *testing.T) {
// for its answer, another command can take the state directory lock and
// change the secret, and that the removal then removes nothing, since the
// secret is no longer what the question named.
//
//nolint:paralleltest // times commands against the in-memory lock all tests share
func TestRemovalAsksWithoutHoldingLock(t *testing.T) {
t.Parallel()
r := newRemoval(t, "rm")
answers, answerWriter := io.Pipe()
+5 -1
View File
@@ -52,8 +52,12 @@ func runSecretWithStdin(stdin string, env map[string]string, args ...string) (st
return cli.ExecuteCommandInProcess(args, stdin, env)
}
// TestMain runs before all tests and ensures the binary is built
// TestMain runs before all tests and ensures the binary is built. It also
// makes passphrase encryption in the tests cheap (see
// secret.ScryptWorkFactor); the binary keeps age's work factor.
func TestMain(m *testing.M) {
secret.ScryptWorkFactor = 1
// Get the current working directory
wd, err := os.Getwd()
if err != nil {
+6 -4
View File
@@ -94,9 +94,9 @@ func numbered(prefix string, count int) []string {
// 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 // times commands against the in-memory lock all tests share
func TestConcurrentAddsKeepEveryVersion(t *testing.T) {
t.Parallel()
mnemonic := testMnemonicBuffer(t)
const adds = 8
@@ -110,6 +110,8 @@ func TestConcurrentAddsKeepEveryVersion(t *testing.T) {
{"real", afero.NewOsFs(), t.TempDir()},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, err := vault.CreateVault(tc.fs, tc.stateDir, "default", mnemonic, nil)
require.NoError(t, err)
@@ -235,9 +237,9 @@ func TestEncryptPipedIntoAdd(t *testing.T) {
// TestFailedCommandReleasesLock checks that a command failing after it
// took the state directory lock leaves the lock free for the next command.
//
//nolint:paralleltest // times commands against the in-memory lock all tests share
func TestFailedCommandReleasesLock(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
cli := NewCLIInstanceWithStateDir(fs, testStateDir)
+16 -4
View File
@@ -15,11 +15,11 @@ import (
// TestGetCommandOutputsToStdout tests that 'secret get' outputs the secret
// value to stdout, not stderr
func TestGetCommandOutputsToStdout(t *testing.T) {
// Create a temporary directory for our vault
tempDir := t.TempDir()
t.Parallel()
// Set environment variables for the test
t.Setenv(secret.EnvStateDir, tempDir)
// Create a temporary directory for our vault; each command is given it
// in its environment
tempDir := t.TempDir()
// Find the secret binary path
wd, err := filepath.Abs("../..")
@@ -41,6 +41,18 @@ func TestGetCommandOutputsToStdout(t *testing.T) {
output, err := cmd.CombinedOutput()
require.NoError(t, err, "init should succeed: %s", string(output))
// The binary, unlike these tests, encrypts the passphrase unlocker's key
// at age's scrypt work factor, 18. age writes the work factor last on the
// second line of priv.age: "-> scrypt <salt> <work factor>".
vaultDir := filepath.Join(tempDir, "vaults.d", "default")
unlockerName := readFile(t, filepath.Join(vaultDir, "current-unlocker"))
unlockerDir := filepath.Join(vaultDir, "unlockers.d", string(unlockerName))
privAge := readFile(t, filepath.Join(unlockerDir, "priv.age"))
header := strings.SplitN(string(privAge), "\n", 3)
require.Len(t, header, 3, "priv.age should start with an age header")
assert.Regexp(t, `^-> scrypt \S+ 18$`, header[1],
"the passphrase unlocker should be encrypted at scrypt work factor 18")
// Add a secret
//nolint:gosec // G204: test executes the freshly built secret binary
cmd = exec.CommandContext(t.Context(), secretPath, "add", "test/secret")
+15
View File
@@ -28,6 +28,17 @@ var (
// terminal to read the mnemonic from, reading it failed, or it was empty.
var ErrMnemonicNotRead = errors.New("failed to read mnemonic")
// ScryptWorkFactor is, when not zero, the scrypt work factor that
// EncryptWithPassphrase uses instead of age's, 18: log2 of scrypt's cost
// parameter N. Deriving a key with age's takes about a second and 256 MiB, on
// purpose, since so does every guess at the passphrase. Only tests set it,
// lower, before any test runs, so that the passphrase unlockers they create
// cost nothing; the program leaves it zero. Decryption takes the work factor
// from the encrypted data, so it needs no setting.
//
//nolint:gochecknoglobals // set by the tests of the packages that use this one
var ScryptWorkFactor int
// EncryptToRecipient encrypts data to a recipient using age
// The data parameter should be a LockedBuffer for secure memory handling
func EncryptToRecipient(
@@ -142,6 +153,10 @@ func EncryptWithPassphrase(
return nil, fmt.Errorf("failed to create scrypt recipient: %w", err)
}
if ScryptWorkFactor != 0 {
recipient.SetWorkFactor(ScryptWorkFactor)
}
return EncryptToRecipient(data, recipient)
}
+8
View File
@@ -25,6 +25,14 @@ var (
errNotImplementedInMock = errors.New("not implemented in mock")
)
// TestMain makes passphrase encryption in the tests cheap; see
// ScryptWorkFactor.
func TestMain(m *testing.M) {
ScryptWorkFactor = 1
os.Exit(m.Run())
}
// MockVault is a test implementation of the VaultInterface
type MockVault struct {
name string
+9
View File
@@ -3,6 +3,7 @@ package vault_test
import (
"bytes"
"errors"
"os"
"path/filepath"
"slices"
"testing"
@@ -28,6 +29,14 @@ const (
testPassphrase = "test-passphrase"
)
// TestMain makes passphrase encryption in the tests cheap; see
// secret.ScryptWorkFactor.
func TestMain(m *testing.M) {
secret.ScryptWorkFactor = 1
os.Exit(m.Run())
}
// testMnemonicBuffer returns testMnemonic in a locked buffer that is
// destroyed when the test ends.
func testMnemonicBuffer(t *testing.T) *memguard.LockedBuffer {
+3 -3
View File
@@ -1,9 +1,9 @@
#!/bin/sh
# script/cibuild: run the CI build. The Dockerfile runs script/check
# (via make check), so a successful build implies all checks pass.
# The Gitea workflow runs this on push. The memlock ulimit lets the tests
# that lock large secrets in memory (memguard mlocks them) run; under the
# lower limit of a plain `docker build .` they are skipped.
# The Gitea workflow runs this on push. The memlock ulimit lifts the limit
# on memory the tests lock (memguard mlocks secrets); they also pass under
# the lower limit of a plain `docker build .`.
# A cached build checks nothing: a new CHECK_EPOCH on every run makes the
# Dockerfile's check steps run again on an unchanged tree, while its base
# images and module downloads stay cached.