package util import ( "crypto/sha256" "encoding/hex" "strings" "testing" "hy2xs-admin/credential" ) // Здесь был TestSHA224String, который печатал хеш в stdout и ничего не // проверял. Вместе с самой функцией он удалён: формат хранения паролей // предыдущего поколения в v1 недостижим, а тест, не содержащий утверждений, // создавал видимость покрытия. func TestHashPasswordProducesBcrypt(t *testing.T) { hash, err := HashPassword("correct horse battery staple") if err != nil { t.Fatalf("HashPassword: %v", err) } if !IsBcryptHash(hash) { t.Fatalf("HashPassword вернул не bcrypt: %q", hash) } } // Соль обязана делать два хеша одного пароля различными: именно этого не // давал прежний несолёный SHA-224. func TestHashPasswordIsSalted(t *testing.T) { first, err := HashPassword("one password for both") if err != nil { t.Fatalf("HashPassword: %v", err) } second, err := HashPassword("one password for both") if err != nil { t.Fatalf("HashPassword: %v", err) } if first == second { t.Fatal("два хеша одного пароля совпали: соль не применяется") } if !VerifyPassword("one password for both", first) || !VerifyPassword("one password for both", second) { t.Fatal("солёные хеши не проходят проверку") } } // HashPassword проверяет ОБЩИЙ контракт, а не собственную копию правила. // // Здесь стояло `len(strings.TrimSpace(password)) < 6`, и это была третья копия // правила о пароле, расходящаяся с двумя остальными. Тест закреплял её обе // ошибки: он требовал ОТКАЗА на шести пробелах — то есть на значении, которое // контракт продукта принимает, — и не проверял верхнюю границу вовсе. // // Шесть пробелов принимаются намеренно. Пробел объявлен частью пароля, набор // символов не ограничен, и «слабый пароль» здесь не тема: значение назначает // оператор, а хеширование не является местом, где оценивают стойкость. func TestHashPasswordFollowsTheCredentialContract(t *testing.T) { cases := []struct { name string password string }{ {"ровно минимум", strings.Repeat("a", credential.AdminPasswordMinLength)}, {"на символ короче минимума", strings.Repeat("a", credential.AdminPasswordMinLength-1)}, {"шесть пробелов", strings.Repeat(" ", credential.AdminPasswordMinLength)}, {"краевой пробел", "abcde "}, {"ровно максимум в ASCII", strings.Repeat("a", credential.AdminPasswordMaxLength)}, {"на символ длиннее максимума", strings.Repeat("a", credential.AdminPasswordMaxLength+1)}, {"ровно предел bcrypt", strings.Repeat("я", 36)}, {"на букву длиннее предела bcrypt", strings.Repeat("я", 37)}, {"эмодзи ровно в предел", strings.Repeat("😀", 18)}, {"эмодзи за пределом", strings.Repeat("😀", 19)}, {"64 кириллические буквы", strings.Repeat("я", credential.AdminPasswordMaxLength)}, {"перевод строки", "abcde\n"}, {"пусто", ""}, } for _, testCase := range cases { t.Run(testCase.name, func(t *testing.T) { _, err := HashPassword(testCase.password) accepted := err == nil expected := credential.IsValidAdminPassword(testCase.password) if accepted != expected { t.Fatalf( "HashPassword принял=%v, контракт=%v (%d символов, %d байт): %v", accepted, expected, len([]rune(testCase.password)), len(testCase.password), err, ) } }) } } // Контракт обязан быть НЕ ШИРЕ того, что умеет bcrypt. // // Это и было исходным дефектом: контракт принимал 64 символа любого алфавита, а // bcrypt.GenerateFromPassword отвечает ErrPasswordTooLong строго при длине // больше 72 БАЙТ. Проверяются граничные значения, на которых расхождение // проявляется: всё, что контракт принял, обязано хешироваться. func TestEveryPasswordAcceptedByContractCanBeHashed(t *testing.T) { candidates := []string{ strings.Repeat("a", credential.AdminPasswordMinLength), strings.Repeat("a", credential.AdminPasswordMaxLength), strings.Repeat("я", 36), strings.Repeat("😀", 18), strings.Repeat(" ", credential.AdminPasswordMinLength), "пароль-администратора", `p@$$w0rd"\'#;`, "abcde ", } for _, password := range candidates { if !credential.IsValidAdminPassword(password) { t.Fatalf("подготовка: контракт отверг значение из %d байт", len(password)) } hash, err := HashPassword(password) if err != nil { t.Fatalf("контракт принял пароль из %d байт, а хеширование отказало: %v", len(password), err) } if !VerifyPassword(password, hash) { t.Fatalf("пароль из %d байт не проходит проверку собственным хешем", len(password)) } } } func TestVerifyPasswordAcceptsOnlyCorrectPassword(t *testing.T) { hash, err := HashPassword("s3cret-passphrase") if err != nil { t.Fatalf("HashPassword: %v", err) } if !VerifyPassword("s3cret-passphrase", hash) { t.Error("верный пароль отклонён") } if VerifyPassword("s3cret-passphras", hash) { t.Error("неверный пароль принят") } if VerifyPassword("", hash) { t.Error("пустой пароль принят") } } // Главная регрессия: несолёный SHA-224 из предыдущего поколения больше не // является допустимым форматом хранения. Раньше VerifyPassword сравнивал с ним // напрямую и возвращал признак legacy, то есть вход по такому хешу удавался. func TestVerifyPasswordRejectsLegacySha224Hash(t *testing.T) { password := "sysadmin" sum := sha256.Sum224([]byte(password)) legacyHash := hex.EncodeToString(sum[:]) if VerifyPassword(password, legacyHash) { t.Fatal("вход по несолёному SHA-224 всё ещё возможен") } if IsBcryptHash(legacyHash) { t.Fatal("SHA-224 опознан как bcrypt") } } // Любая строка, не являющаяся bcrypt-хешем, — повреждённое или подменённое // значение в базе, а не «другой формат». func TestVerifyPasswordRejectsNonBcryptStorage(t *testing.T) { for _, stored := range []string{ "", "plaintext-password", "$1$abcdefgh$0123456789abcdef", "$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$hash", strings.Repeat("a", 60), } { if VerifyPassword("plaintext-password", stored) { t.Errorf("принят пароль против не-bcrypt значения %q", stored) } } }