Files

270 lines
15 KiB
Go

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},
// C1: раньше документация обещала «без управляющих символов», а код
// проверял только C0 и DEL — то есть обещание было шире проверки.
{"C1 NEL", "abcde" + string(rune(0x85)), false},
{"C1 верхняя граница", "abcde" + string(rune(0x9F)), false},
// Домен systemd. Каждое из этих значений — шесть символов, ни одного
// управляющего, укладывается в 72 байта, — и при этом делает
// /etc/hy2xs/hy2xs.env НЕзагружаемым: `check_utf8ness_and_warn`
// отвечает -EINVAL, и юнит hy2xs-admin не стартует.
{"noncharacter U+FDD0", "abcde" + string(rune(0xFDD0)), false},
{"noncharacter U+FDEF", "abcde" + string(rune(0xFDEF)), false},
{"noncharacter U+FFFE", "abcde" + string(rune(0xFFFE)), false},
{"noncharacter U+FFFF", "abcde" + string(rune(0xFFFF)), false},
{"noncharacter U+1FFFF", "abcde" + string(rune(0x1FFFF)), false},
{"noncharacter U+10FFFF", "abcde" + string(rune(0x10FFFF)), false},
{"невалидный UTF-8", "abcde\xff", false},
// Соседи noncharacters приниматься обязаны: правило описывает ровно
// множество systemd, а не «всё подозрительное рядом».
{"U+FDCF рядом с диапазоном", "abcde" + string(rune(0xFDCF)), true},
{"U+FDF0 рядом с диапазоном", "abcde" + string(rune(0xFDF0)), true},
{"U+FFFD (replacement)", "abcde" + string(rune(0xFFFD)), true},
// U+FEFF отвергается документированным транспортным доменом systemd.
{"U+FEFF", "abcde" + string(rune(0xFEFF)), 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 байт принят: байтовая граница не проверяется")
}
}
// Домен транспорта повторяет публично документированное множество systemd.
//
// Шире означало бы собственный запрет сверх публичного контракта; уже — что
// значение проходит все двери продукта вопреки документации EnvironmentFile.
// Второе и было дефектом: контракт знал про управляющие символы и не знал про
// noncharacters.
//
// Границы взяты из `unichar_is_valid` (systemd, src/basic/utf8.c) и проверяются
// вместе с соседями: правило обязано отвергать ровно диапазон, а не окрестность.
func TestEnvTransportDomainMatchesDocumentedSystemdContract(t *testing.T) {
rejected := []rune{
0x0000, 0xFEFF, // NUL и BOM запрещены публичным контрактом
0xFDD0, 0xFDEF, // U+FDD0..U+FDEF
0xFFFE, 0xFFFF,
0x1FFFE, 0x1FFFF,
0x10FFFE, 0x10FFFF,
}
for _, r := range rejected {
if IsEnvTransportableText(string(r)) {
t.Errorf("U+%04X принят вопреки документированному контракту EnvironmentFile", r)
}
}
accepted := []rune{
0x0020, 0x007F, 0x0085, // управляющие формат НЕСЁТ; их запрещает политика продукта
0xFDCF, 0xFDF0, // соседи зарезервированного диапазона
0xFFFD, // replacement character — обычный символ
0x10FFFD, // последний scalar value, не noncharacter
0x1F600, // 😀
}
for _, r := range accepted {
if !IsEnvTransportableText(string(r)) {
t.Errorf("U+%04X отвергнут: правило шире документированного множества systemd", r)
}
}
// Невалидный UTF-8 — не «строка со странными символами», а байты, которые
// systemd отвергает первой же проверкой.
for _, invalid := range []string{"\xff", "\xc3", "abc\xed\xa0\x80"} {
if IsEnvTransportableText(invalid) {
t.Errorf("невалидный UTF-8 %q принят", invalid)
}
}
}
// Продуктовая политика и домен systemd — РАЗНЫЕ множества, и это видно.
//
// Пока они были склеены, невозможно было ни объяснить отказ, ни заметить, что
// одно из двух правил отсутствует.
func TestProductPolicyIsWiderThanTransportDomain(t *testing.T) {
// Управляющие символы формат несёт — их запрещает HY2XS.
for _, r := range []rune{0x0A, 0x0D, 0x09, 0x7F, 0x85, 0x9F} {
password := "abcde" + string(r)
if !IsEnvTransportableText(password) && r != 0x00 {
t.Errorf("U+%04X: ожидалось, что формат его несёт", r)
}
if IsValidAdminPassword(password) {
t.Errorf("U+%04X принят паролем вопреки политике продукта", r)
}
}
if IsEnvTransportableText("abcde" + string(rune(0xFEFF))) {
t.Error("U+FEFF принят вопреки документированному контракту EnvironmentFile")
}
}
// Пароль НЕ триммится нигде, включая сам контракт.
//
// Пока `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("пароль с краевым пробелом отклонён: пробел объявлен частью значения")
}
}