package credential import ( "strings" "testing" "unicode/utf8" ) // Набор символов логина администратора закреплён ФАКТИЧЕСКИМ множеством. // // Прежняя запись класса `[a-zA-Z0-9!@#$%^&*()_+-=]` содержала неэкранированный // дефис, из-за чего `+-=` образовывал диапазон и впускал `, - . / 0-9 : ; < =`. // Действующая запись перечисляет эти символы явно и НЕ сужает множество: имя // администратора приходит из HY2XS_ADMIN_USER в hy2xs.env, и сужение правила // означало бы, что установка с логином вроде `admin.ops` перестаёт пускать // оператора в панель. // // Тест существует, чтобы это решение было явным: попытка «навести порядок» в // классе символов уронит его, а не вход администратора на живом сервере. func TestAdminUsernameCharsetIsUnchanged(t *testing.T) { const historical = "abcXYZ019" + "!@#$%^&*()_" + "+,-./:;<=" for _, symbol := range strings.Split(historical, "") { candidate := "admin" + symbol if !IsValidAdminUsername(candidate) { t.Errorf("символ %q больше не принимается логином: сужение набора ломает вход существующей установки", symbol) } } for _, rejected := range []string{ "", // не задано "admi", // короче шести символов strings.Repeat("a", 33), // длиннее тридцати двух "admin пробел", // пробел "админ1", // кириллица "admin\n1", // перевод строки "admin'1", // апостроф вне набора } { if IsValidAdminUsername(rejected) { t.Errorf("значение %q принято логином, ожидался отказ", rejected) } } } // Границы длины логина принадлежат ОДНОМУ правилу, и человекочитаемый набор // обязан описывать тот же класс, что и регексп. // // Расхождение здесь тихо превращает сообщение об отказе в дезинформацию: // оператор читает набор, которого правило не принимает. func TestAdminUsernameBoundsAgreeWithRule(t *testing.T) { if IsValidAdminUsername(strings.Repeat("a", AdminUsernameMinLength-1)) { t.Errorf("логин длиной %d принят при минимуме %d", AdminUsernameMinLength-1, AdminUsernameMinLength) } if !IsValidAdminUsername(strings.Repeat("a", AdminUsernameMinLength)) { t.Errorf("логин длиной ровно %d отклонён", AdminUsernameMinLength) } if !IsValidAdminUsername(strings.Repeat("a", AdminUsernameMaxLength)) { t.Errorf("логин длиной ровно %d отклонён", AdminUsernameMaxLength) } if IsValidAdminUsername(strings.Repeat("a", AdminUsernameMaxLength+1)) { t.Errorf("логин длиной %d принят при максимуме %d", AdminUsernameMaxLength+1, AdminUsernameMaxLength) } // Человекочитаемый набор перечисляет ровно те же знаки препинания, что и // класс регекспа. Пробелы в нём — разделители групп, а не допустимый символ. punctuation := strings.Fields(AdminUsernameCharset) for _, symbol := range strings.Split(punctuation[len(punctuation)-1], "") { if !IsValidAdminUsername("admin" + symbol) { t.Errorf("набор из сообщения обещает символ %q, но правило его не принимает", symbol) } } } // Длина пароля считается В СИМВОЛАХ, а предел bcrypt — В БАЙТАХ, и это разные // границы. // // Здесь был дефект. Верхняя граница в 64 символа объявлялась «заведомо ниже 72 // байт», и это верно ТОЛЬКО для ASCII. Тест, закреплявший поведение, вдобавок // требовал приёма 64 кириллических букв — то есть 128 байт — и тем самым // фиксировал как ожидаемое ровно то значение, на котором продукт ломался: // оркестратор его пропускал, DTO пропускало, а bcrypt отвечал // ErrPasswordTooLong уже при создании учётной записи. func TestAdminPasswordBoundaries(t *testing.T) { // Проверка самой таблицы: если «я» перестанет быть двухбайтовой, а «😀» — // четырёхбайтовым, случаи ниже проверяли бы не то, что написано. if got := len(strings.Repeat("я", 36)); got != AdminPasswordMaxBytes { t.Fatalf("подготовка: 36 x «я» = %d байт, ожидалось %d", got, AdminPasswordMaxBytes) } if got := len(strings.Repeat("😀", 18)); got != AdminPasswordMaxBytes { t.Fatalf("подготовка: 18 x «😀» = %d байт, ожидалось %d", got, AdminPasswordMaxBytes) } cases := []struct { name string password string accepted bool }{ {"ровно минимум", strings.Repeat("a", AdminPasswordMinLength), true}, {"на символ короче минимума", strings.Repeat("a", AdminPasswordMinLength-1), false}, {"ровно максимум в ASCII", strings.Repeat("a", AdminPasswordMaxLength), true}, {"на символ длиннее максимума", strings.Repeat("a", AdminPasswordMaxLength+1), false}, // Граница bcrypt проходит ВНУТРИ разрешённого диапазона символов. {"36 x «я» = 72 байта", strings.Repeat("я", 36), true}, {"37 x «я» = 74 байта", strings.Repeat("я", 37), false}, {"18 x «😀» = 72 байта", strings.Repeat("😀", 18), true}, {"19 x «😀» = 76 байт", strings.Repeat("😀", 19), false}, // Исходный дефект: 64 символа, 128 байт. Прежний тест требовал ПРИЁМА. {"64 x «я» = 128 байт", strings.Repeat("я", AdminPasswordMaxLength), false}, // Пробелы по краям — часть пароля, а не мусор ввода. {"пробел на конце", "abcde ", true}, {"пробел в начале", " abcde", true}, {"только пробелы", strings.Repeat(" ", AdminPasswordMinLength), true}, // Набор символов не ограничен ничем, кроме управляющих. {"кириллица", "пароль-администратора", true}, {"знаки препинания", `p@$$w0rd"\'#;`, true}, {"перевод строки", "abcde\n", false}, {"возврат каретки", "abcde\r", false}, {"табуляция", "abcde\t", false}, {"нулевой байт", "abcde\x00", false}, {"DEL", "abcde\x7f", false}, } for _, testCase := range cases { t.Run(testCase.name, func(t *testing.T) { if got := IsValidAdminPassword(testCase.password); got != testCase.accepted { t.Errorf( "IsValidAdminPassword = %v, ожидалось %v (%d символов, %d байт)", got, testCase.accepted, utf8.RuneCountInString(testCase.password), len(testCase.password), ) } }) } } // Предел в байтах не выводится из предела в символах, и обратное неверно тоже. // // Проверка защищает от «упрощения», при котором одну из двух констант объявят // производной от другой: у 64 символов длина от 64 до 256 байт. func TestAdminPasswordLimitsAreIndependent(t *testing.T) { if AdminPasswordMaxBytes != 72 { t.Fatalf( "предел bcrypt изменён на %d: GenerateFromPassword отвечает ErrPasswordTooLong строго при len > 72", AdminPasswordMaxBytes, ) } // Пароль, укладывающийся в символы и не укладывающийся в байты, обязан // существовать — иначе байтовая граница недостижима и ничего не проверяет. long := strings.Repeat("я", AdminPasswordMaxLength) if utf8.RuneCountInString(long) > AdminPasswordMaxLength { t.Fatal("подготовка: значение длиннее предела в символах, проверяется не та граница") } if IsValidAdminPassword(long) { t.Fatal("пароль в 128 байт принят: байтовая граница не проверяется") } } // Пароль НЕ триммится нигде, включая сам контракт. // // Пока `util.HashPassword` вёл собственную проверку через strings.TrimSpace, // значение `"abcde "` считалось корректным всеми дверями продукта и не могло // быть захешировано. func TestAdminPasswordKeepsSurroundingWhitespace(t *testing.T) { // Шесть символов, из которых последний — пробел. По длине без пробела это // пять символов, то есть прежняя проверка отказала бы. const password = "abcde " if utf8.RuneCountInString(strings.TrimSpace(password)) >= AdminPasswordMinLength { t.Fatal("подготовка: значение проходит и после тримминга, проверяется не то") } if !IsValidAdminPassword(password) { t.Error("пароль с краевым пробелом отклонён: пробел объявлен частью значения") } }