From 508f246b5b3e6390e4d083fe4d3339a885f7c502 Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 1 Oct 2026 19:32:37 +0000 Subject: [PATCH] Keep make test under 2 GB of memory (closes #344) Every test that starts a database hashed the bootstrap admin password with Argon2id at 64 MB, which under -race holds about 150 MB per hash, across dozens of parallel tests. HashPassword now hashes at 1 MB when testing.Testing() reports a test binary, so every test package gets the lower cost and a binary built by go build never does. One test still hashes and verifies through HashPassword at the shipped parameters, which are unchanged. script/test also runs at most four packages and eight parallel tests at once: unbounded, a many-core host linked and ran every test binary together. Model: opus-5-5 --- internal/database/export_test.go | 12 +++++++++++ internal/database/password.go | 23 ++++++++++++++++++++- internal/database/password_test.go | 33 ++++++++++++++++++++++++++++++ internal/resetpw/resetpw_test.go | 2 +- script/test | 7 ++++++- 5 files changed, 74 insertions(+), 3 deletions(-) diff --git a/internal/database/export_test.go b/internal/database/export_test.go index 5c10271..7ad4280 100644 --- a/internal/database/export_test.go +++ b/internal/database/export_test.go @@ -5,6 +5,7 @@ import ( "io" "log/slog" "os" + "testing" "time" "go.uber.org/fx" @@ -79,3 +80,14 @@ func (d *Database) ExportSetBannerOut(w io.Writer) { func DummyPasswordHashForTest() string { return dummyPasswordHash() } + +// HashAtShippedCostForTest makes HashPassword hash at the shipped +// memory cost until t ends. t must not run in parallel with other +// tests, which would hash at that cost alongside it. +func HashAtShippedCostForTest(t *testing.T) { + t.Helper() + + hashAtShippedCostInTest = true + + t.Cleanup(func() { hashAtShippedCostInTest = false }) +} diff --git a/internal/database/password.go b/internal/database/password.go index 92ce50a..e114cb6 100644 --- a/internal/database/password.go +++ b/internal/database/password.go @@ -9,6 +9,7 @@ import ( "math/big" "strings" "sync" + "testing" "golang.org/x/crypto/argon2" ) @@ -63,10 +64,30 @@ func DefaultPasswordConfig() *PasswordConfig { } } -// HashPassword generates an Argon2id hash of the password +// testArgon2Memory is the Argon2id memory cost, in KiB, that a test +// binary hashes with: 1 MB instead of the shipped 64 MB. Every test +// that starts a database hashes the bootstrap admin password, dozens +// of them run in parallel, and under the race detector each 64 MB hash +// holds about 150 MB. VerifyPassword reads the cost from the hash it +// checks, so verification follows. +const testArgon2Memory = 1024 + +// hashAtShippedCostInTest makes a test binary hash at the shipped +// memory cost. Only TestHashPassword_ShippedParameters sets it. +// +//nolint:gochecknoglobals // set by one test, see above +var hashAtShippedCostInTest bool + +// HashPassword generates an Argon2id hash of the password. A binary +// built by go test hashes at testArgon2Memory; one built by go build +// always hashes at the defaults. func HashPassword(password string) (string, error) { config := DefaultPasswordConfig() + if testing.Testing() && !hashAtShippedCostInTest { + config.Memory = testArgon2Memory + } + // Generate a salt salt := make([]byte, config.SaltLen) diff --git a/internal/database/password_test.go b/internal/database/password_test.go index e3f4726..d7f9448 100644 --- a/internal/database/password_test.go +++ b/internal/database/password_test.go @@ -192,6 +192,39 @@ func TestHashPasswordUniqueness(t *testing.T) { } } +// TestHashPassword_ShippedParameters hashes and verifies through +// HashPassword at the shipped Argon2id parameters. Every other test +// hashes at the lower memory cost a test binary uses, so this is the +// one that keeps production hashing covered. One hash and one +// verification: each costs 64 MB. +// +//nolint:paralleltest // changes the hashing cost for the whole binary +func TestHashPassword_ShippedParameters(t *testing.T) { + database.HashAtShippedCostForTest(t) + + password := "correct horse battery staple" + + hash, err := database.HashPassword(password) + if err != nil { + t.Fatalf("hashing with the shipped parameters: %v", err) + } + + const shipped = "$argon2id$v=19$m=65536,t=1,p=4$" + + if !strings.HasPrefix(hash, shipped) { + t.Errorf("hash = %q, want prefix %q", hash, shipped) + } + + valid, err := database.VerifyPassword(password, hash) + if err != nil { + t.Fatalf("VerifyPassword() error = %v", err) + } + + if !valid { + t.Error("VerifyPassword() returned false for correct password") + } +} + // TestVerifyDummyPassword_DoesRealWork covers the anti-enumeration // path. Login charges an unknown username a verification against a // dummy hash so that a nonexistent account is not answered in diff --git a/internal/resetpw/resetpw_test.go b/internal/resetpw/resetpw_test.go index cca9107..495dfc4 100644 --- a/internal/resetpw/resetpw_test.go +++ b/internal/resetpw/resetpw_test.go @@ -140,7 +140,7 @@ func (n *noopEvictor) EvictWebhook(string) {} // and the database, exactly as internal/handlers builds them. // // One application per test function, not per case: every start that -// finds no account seeds one at 64 MB of Argon2id, and this package's +// finds no account seeds one with an Argon2id hash, and this package's // budget is not the place to spend that repeatedly. func newServerApp( t *testing.T, dir string, diff --git a/script/test b/script/test index 44f0308..301ae21 100755 --- a/script/test +++ b/script/test @@ -22,6 +22,11 @@ # The one figure above 90s is GOMAXPROCS 1, a synthetic core floor rather than # a condition CI runs under. If a CPU-limited runner ever puts a real run near # 67s, that is the datum to revisit the org figure with. +# +# -p 4 -parallel 8 keep the run under 2 GB of memory: at most four test +# binaries build or run at once, each with at most eight parallel tests. Under +# -race every test binary and every link costs a few hundred MB, so the +# defaults (one per core) add up to several GB on a many-core host. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" @@ -29,7 +34,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" main() { cd "$ROOT" "$ROOT/script/assets" - go test -v -race -timeout 90s ./... + go test -v -race -p 4 -parallel 8 -timeout 90s ./... } main "$@" -- 2.54.0