diff --git a/internal/database/export_test.go b/internal/database/export_test.go index 5c10271..1977da2 100644 --- a/internal/database/export_test.go +++ b/internal/database/export_test.go @@ -79,3 +79,9 @@ func (d *Database) ExportSetBannerOut(w io.Writer) { func DummyPasswordHashForTest() string { return dummyPasswordHash() } + +// HashPasswordWithDefaultsForTest hashes with the shipped Argon2id +// parameters, which TestMain has lowered for HashPassword itself. +func HashPasswordWithDefaultsForTest(password string) (string, error) { + return hashPasswordWith(password, DefaultPasswordConfig()) +} diff --git a/internal/database/main_test.go b/internal/database/main_test.go new file mode 100644 index 0000000..e158dcf --- /dev/null +++ b/internal/database/main_test.go @@ -0,0 +1,14 @@ +package database_test + +import ( + "testing" + + "sneak.berlin/go/webhooker/internal/database" +) + +// TestMain lowers the password hashing cost before any test runs. See +// database.LowerPasswordHashCostForTest. +func TestMain(m *testing.M) { + database.LowerPasswordHashCostForTest() + m.Run() +} diff --git a/internal/database/password.go b/internal/database/password.go index 92ce50a..c65097a 100644 --- a/internal/database/password.go +++ b/internal/database/password.go @@ -63,10 +63,24 @@ func DefaultPasswordConfig() *PasswordConfig { } } +// hashConfig is what HashPassword hashes with: the defaults above. +// Only a test binary changes it, through LowerPasswordHashCostForTest, +// before any test runs. +// +//nolint:gochecknoglobals // see above +var hashConfig = DefaultPasswordConfig() + // HashPassword generates an Argon2id hash of the password func HashPassword(password string) (string, error) { - config := DefaultPasswordConfig() + return hashPasswordWith(password, hashConfig) +} +// hashPasswordWith generates an Argon2id hash of the password with +// the given parameters. +func hashPasswordWith( + password string, + config *PasswordConfig, +) (string, error) { // Generate a salt salt := make([]byte, config.SaltLen) diff --git a/internal/database/password_test.go b/internal/database/password_test.go index e3f4726..2f012e5 100644 --- a/internal/database/password_test.go +++ b/internal/database/password_test.go @@ -192,6 +192,36 @@ func TestHashPasswordUniqueness(t *testing.T) { } } +// TestHashPassword_ShippedParameters hashes and verifies at the +// shipped Argon2id parameters. Every other test hashes at the lowered +// memory cost TestMain sets, so this is the one that keeps production +// hashing covered. One hash and one verification: each costs 64 MB. +func TestHashPassword_ShippedParameters(t *testing.T) { + t.Parallel() + + password := "correct horse battery staple" + + hash, err := database.HashPasswordWithDefaultsForTest(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/database/testing.go b/internal/database/testing.go index 72c8816..3af0281 100644 --- a/internal/database/testing.go +++ b/internal/database/testing.go @@ -45,3 +45,20 @@ func NewTestWebhookDBManagerWithLogger( log: log, } } + +// testArgon2Memory is the Argon2id memory cost, in KiB, that test +// binaries hash with: 1 MB instead of the shipped 64 MB. +const testArgon2Memory = 1024 + +// LowerPasswordHashCostForTest makes HashPassword use a 1 MB Argon2id +// memory cost instead of the shipped 64 MB, for the rest of the +// process. Call it from TestMain, before any test runs. +// +// Every test that starts a database hashes the bootstrap admin +// password, and a package runs dozens of those tests in parallel. +// Under the race detector each 64 MB hash holds about 150 MB resident. +// VerifyPassword reads the cost from the hash it checks, so +// verification follows. Production code never calls this. +func LowerPasswordHashCostForTest() { + hashConfig.Memory = testArgon2Memory +} diff --git a/internal/gormlog/main_test.go b/internal/gormlog/main_test.go new file mode 100644 index 0000000..3d73d77 --- /dev/null +++ b/internal/gormlog/main_test.go @@ -0,0 +1,14 @@ +package gormlog_test + +import ( + "testing" + + "sneak.berlin/go/webhooker/internal/database" +) + +// TestMain lowers the password hashing cost before any test runs. See +// database.LowerPasswordHashCostForTest. +func TestMain(m *testing.M) { + database.LowerPasswordHashCostForTest() + m.Run() +} diff --git a/internal/handlers/main_test.go b/internal/handlers/main_test.go new file mode 100644 index 0000000..f7dd3ba --- /dev/null +++ b/internal/handlers/main_test.go @@ -0,0 +1,14 @@ +package handlers_test + +import ( + "testing" + + "sneak.berlin/go/webhooker/internal/database" +) + +// TestMain lowers the password hashing cost before any test runs. See +// database.LowerPasswordHashCostForTest. +func TestMain(m *testing.M) { + database.LowerPasswordHashCostForTest() + m.Run() +} diff --git a/internal/resetpw/main_test.go b/internal/resetpw/main_test.go new file mode 100644 index 0000000..fffed8f --- /dev/null +++ b/internal/resetpw/main_test.go @@ -0,0 +1,14 @@ +package resetpw_test + +import ( + "testing" + + "sneak.berlin/go/webhooker/internal/database" +) + +// TestMain lowers the password hashing cost before any test runs. See +// database.LowerPasswordHashCostForTest. +func TestMain(m *testing.M) { + database.LowerPasswordHashCostForTest() + m.Run() +} 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/internal/server/main_test.go b/internal/server/main_test.go new file mode 100644 index 0000000..e1c387e --- /dev/null +++ b/internal/server/main_test.go @@ -0,0 +1,14 @@ +package server_test + +import ( + "testing" + + "sneak.berlin/go/webhooker/internal/database" +) + +// TestMain lowers the password hashing cost before any test runs. See +// database.LowerPasswordHashCostForTest. +func TestMain(m *testing.M) { + database.LowerPasswordHashCostForTest() + m.Run() +} diff --git a/script/test b/script/test index bfa9129..87f00cf 100755 --- a/script/test +++ b/script/test @@ -22,13 +22,18 @@ # 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)" main() { cd "$ROOT" - go test -v -race -timeout 90s ./... + go test -v -race -p 4 -parallel 8 -timeout 90s ./... } main "$@"