check / check (push) Successful in 3m21s
The `main` side of #414: the two changes that make `next` green, and nothing else from `next`. Each is its own commit, so it can be compared with its `next` counterpart. - #404, as merged to `next`: a test binary hashes passwords at a 1 MB Argon2id cost instead of 64 MB, `TestHashPassword_ShippedParameters` keeps the shipped cost covered, and `script/test` runs at most four packages and eight parallel tests at once. Every test that starts a database hashed the admin password at 64 MB, which on a busy host made `internal/handlers` overrun its application start and its 90-second timeout. That is what turned `main` red. - #415: `script/test` runs without `-v`, so the build log, which the Docker build cuts off at 2 MiB, carries one result line per package and, for a package that fails, everything its tests wrote, application log lines included, instead of only passing packages. What the diff does not show: `script/test` differs from `next` by one line. `main` has no `script/assets` yet, so it is not called. Several packages failing at once can still reach the 2 MiB limit. - Judgement call: both commits keep their subjects from `next`, including their `closes` references. - Deviation and not fixed here: the same two as on #415 (no `-v` rerun on failure, #315; remaining sensitivity to extreme CPU load, #225). Model: opus-5-5 Co-authored-by: sneak <sneak@sneak.berlin> Reviewed-on: #416 Co-authored-by: clawbot <35+clawbot@noreply.example.org>
265 lines
5.6 KiB
Go
265 lines
5.6 KiB
Go
package database_test
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
)
|
|
|
|
func TestGenerateRandomPassword(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
length int
|
|
}{
|
|
{"Short password", 8},
|
|
{"Medium password", 16},
|
|
{"Long password", 32},
|
|
{"Very short password", 3},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
password, err := database.GenerateRandomPassword(
|
|
tt.length,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf(
|
|
"GenerateRandomPassword() error = %v",
|
|
err,
|
|
)
|
|
}
|
|
|
|
if len(password) != tt.length {
|
|
t.Errorf(
|
|
"Password length = %v, want %v",
|
|
len(password), tt.length,
|
|
)
|
|
}
|
|
|
|
checkPasswordComplexity(
|
|
t, password, tt.length,
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
func checkPasswordComplexity(
|
|
t *testing.T,
|
|
password string,
|
|
length int,
|
|
) {
|
|
t.Helper()
|
|
|
|
// For passwords >= 4 chars, check complexity
|
|
if length < 4 {
|
|
return
|
|
}
|
|
|
|
flags := classifyChars(password)
|
|
|
|
if !flags[0] || !flags[1] || !flags[2] || !flags[3] {
|
|
t.Errorf(
|
|
"Password lacks required complexity: "+
|
|
"upper=%v, lower=%v, digit=%v, special=%v",
|
|
flags[0], flags[1], flags[2], flags[3],
|
|
)
|
|
}
|
|
}
|
|
|
|
func classifyChars(s string) [4]bool {
|
|
var flags [4]bool // upper, lower, digit, special
|
|
|
|
for _, char := range s {
|
|
switch {
|
|
case char >= 'A' && char <= 'Z':
|
|
flags[0] = true
|
|
case char >= 'a' && char <= 'z':
|
|
flags[1] = true
|
|
case char >= '0' && char <= '9':
|
|
flags[2] = true
|
|
case strings.ContainsRune(
|
|
"!@#$%^&*()_+-=[]{}|;:,.<>?",
|
|
char,
|
|
):
|
|
flags[3] = true
|
|
}
|
|
}
|
|
|
|
return flags
|
|
}
|
|
|
|
func TestGenerateRandomPasswordUniqueness(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Generate multiple passwords and ensure they're different
|
|
passwords := make(map[string]bool)
|
|
|
|
const numPasswords = 100
|
|
|
|
for range numPasswords {
|
|
password, err := database.GenerateRandomPassword(16)
|
|
if err != nil {
|
|
t.Fatalf(
|
|
"GenerateRandomPassword() error = %v",
|
|
err,
|
|
)
|
|
}
|
|
|
|
if passwords[password] {
|
|
t.Errorf(
|
|
"Duplicate password generated: %s",
|
|
password,
|
|
)
|
|
}
|
|
|
|
passwords[password] = true
|
|
}
|
|
}
|
|
|
|
func TestHashPassword(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
password := "testPassword123!"
|
|
|
|
hash, err := database.HashPassword(password)
|
|
if err != nil {
|
|
t.Fatalf("HashPassword() error = %v", err)
|
|
}
|
|
|
|
// Check that hash has correct format
|
|
if !strings.HasPrefix(hash, "$argon2id$") {
|
|
t.Errorf(
|
|
"Hash doesn't have correct prefix: %s",
|
|
hash,
|
|
)
|
|
}
|
|
|
|
// Verify password
|
|
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",
|
|
)
|
|
}
|
|
|
|
// Verify wrong password fails
|
|
valid, err = database.VerifyPassword(
|
|
"wrongPassword", hash,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("VerifyPassword() error = %v", err)
|
|
}
|
|
|
|
if valid {
|
|
t.Error(
|
|
"VerifyPassword() returned true " +
|
|
"for wrong password",
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestHashPasswordUniqueness(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
password := "testPassword123!"
|
|
|
|
// Same password should produce different hashes
|
|
hash1, err := database.HashPassword(password)
|
|
if err != nil {
|
|
t.Fatalf("HashPassword() error = %v", err)
|
|
}
|
|
|
|
hash2, err := database.HashPassword(password)
|
|
if err != nil {
|
|
t.Fatalf("HashPassword() error = %v", err)
|
|
}
|
|
|
|
if hash1 == hash2 {
|
|
t.Error(
|
|
"Same password produced identical hashes " +
|
|
"(salt not working)",
|
|
)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
// microseconds where a real one takes tens of milliseconds. That only
|
|
// works if the dummy hash is a real, decodable Argon2id hash: a
|
|
// malformed one would make VerifyPassword fail on the decode and
|
|
// return before hashing anything.
|
|
func TestVerifyDummyPassword_DoesRealWork(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Runs the OnceValue that builds the dummy hash, so a panic in
|
|
// it surfaces here rather than on a live login.
|
|
database.VerifyDummyPassword("whatever was submitted")
|
|
|
|
dummy := database.DummyPasswordHashForTest()
|
|
|
|
// A hash the verifier cannot decode would make VerifyPassword
|
|
// return on the decode error, before hashing anything — the
|
|
// timing oracle this path exists to close.
|
|
valid, err := database.VerifyPassword("whatever", dummy)
|
|
if err != nil {
|
|
t.Fatalf(
|
|
"the dummy hash must decode like a real one: %v", err,
|
|
)
|
|
}
|
|
|
|
if valid {
|
|
t.Error("nothing may authenticate against the dummy hash")
|
|
}
|
|
|
|
if !strings.HasPrefix(dummy, "$argon2id$") {
|
|
t.Errorf(
|
|
"the dummy hash must use the same algorithm as real "+
|
|
"hashes, got %q", dummy,
|
|
)
|
|
}
|
|
}
|