fix(admin): вход в панель падал на теге правила, пережившего переименование
RC2 на чистом Debian 13 завершался INSTALL EXIT CODE: 0 при полностью недоступной панели. На LoginDto.Username стоял тег `validateStr` — правило с таким именем не регистрировалось: при переименовании в `credentialStr` правка не доехала до одного файла, оставив мёртвую регистрацию и живую ссылку на несуществующее имя. go-playground/validator на неизвестный тег ПАНИКУЕТ при разборе структуры, то есть до всякой проверки логина и пароля, а gin.Recovery превращал панику в HTTP 500 на каждый POST /api/auth/login. Дефект пережил 311 Go-тестов, и это главное, что здесь чинится. Проверялся сам регексп, в обход валидатора, а обработчика входа не касался ни один тест. Очевидная замена не помогла бы: цепочка правил поля обрывается на первом несработавшем, поэтому нулевое DTO отказывает по `required` и до испорченного тега не доходит. Теперь TestEveryValidationTagIsRegistered обходит исходники apps/model/**, вытаскивает каждый тег `validate:"…"` и предъявляет его валидатору отдельно — незарегистрированное правило паникует так же, как в бою, но на сборке. Барьер проверен возвратом исходного тега. Установка тоже не отвечала на вопрос, ради которого проверялась. Smoke считал панель работающей по трём признакам — юнит активен, порт в LISTEN, /healthz отвечает ok, — и все три были истинны. Теперь smoke выполняет настоящий вход bootstrap-учётными данными и требует конверт успеха с непустым токеном: по коду HTTP это неотличимо, админка отвечает 200 OK и на отказ. Отрицательная проба идёт в любом режиме операции и от актуальности пароля не зависит. Рядом лежали три расхождения того же класса, найденные при разборе. Оркестратор не знал контракта, который сам порождает: HY2XS_ADMIN_USER по умолчанию был `admin` — пять символов при минимуме панели в шесть, — и такая установка проходила целиком, создавая учётную запись, под которой невозможно войти. Про одно имя существовало три расходящихся умолчания. Оба значения теперь проверяются при разборе окружения — той стороной, которая их порождает: отказ, пришедший установщику, чинится строкой в hy2xs.env, а неработающий вход на готовом сервере — переустановкой. Панель была строже сервера. Форма входа ограничивала пароль 32 символами при серверном пределе в 64, а форма смены пароля назначала до 64: пароль, назначенный штатной операцией, после этого не вводился. Набор символов на пароле отвергал значение, которое сервер принял бы, — сервер его не ограничивает нигде. Контракт учётных данных объявлен один раз в service/admin_credentials.go, копии в панели и оркестраторе сверяются с ним тестами, читающими Go-исходник. Класс символов логина был записан диапазоном по опечатке: неэкранированный дефис превращал `+-=` в диапазон, впускающий `, - . / 0-9 : ; < =`. С серверным набором это совпадало только потому, что обе стороны несли одну опечатку. Набор записан явно и НЕ сужен — он уже действует на установленных серверах. Визуально: красная рамка отказа обводила не то, что видит оператор. Element Plus рисует состояние ошибки на el-input__wrapper селектором из четырёх классов, а форма входа рисует видимую рамку поля на el-form-item — внутрь поля кладутся иконка, ввод и переключатель видимости — и гасила чужую тень селектором из трёх, проигрывая по специфичности. Рамка ложилась вокруг одного лишь ввода: у логина начиналась после иконки, у пароля обрывалась перед «глазом». Индикация перенесена на элемент, который оператор и видит полем; чужая тень гасится селектором, повторяющим её собственный и добавляющим атрибут scoped-стиля, — конкретностью, а не !important. Остальные формы панели проверены: собственная рамка на el-form-item есть только на форме входа. Заодно: `last_login_at` объявлен в схеме и в entity, а писать его было некому — UpdateAdminLastLoginAt не вызывался ниоткуда. Отметка ставится в service.Login сразу после успешной проверки пароля; отказ записи вход не отменяет, но попадает в журнал. Обработчик входа переехал из controller/peer.go в controller/auth.go: стек в journal указывал на управление пирами. Требование теперь называется, а не сообщается фактом нарушения. «Неверный формат логина» и «Некорректное значение» не давали оператору способа узнать, что от него хотят: набор символов приходит из hy2xs.env и в панели нигде не показан. Фразы форм и серверная причина credential_format перечисляют границы и набор. Гейт сборки run_admin_login_acceptance удерживает барьеры от тихого удаления — по той же причине, что и гейт детектора гонок. Каждое из его утверждений проверено мутационной пробой на реальный отказ; две первые редакции оказались вакуумными и переписаны. Прогнано: go vet + go test ./... , bun test оркестратора (427) и контрактов панели (66), vue-tsc --noEmit, production-сборка frontend, гейт приёмки целиком. `go test -race` не прогонялся — на машине нет C-компилятора, это релизный гейт сборщика. Прогон задокументирован в docs/acceptance/2026-09-04-v1.0.0-rc2-runtime-findings.md.
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/model/dto"
|
||||
"hy2xs-admin/model/vo"
|
||||
"hy2xs-admin/service"
|
||||
)
|
||||
|
||||
// Вход в панель живёт в собственном файле, а не среди обработчиков пиров.
|
||||
//
|
||||
// Это не косметика. Пока Login лежал в controller/peer.go, паника валидатора на
|
||||
// форме входа приходила в journal стеком `controller/peer.go:50`, и первая
|
||||
// гипотеза при разборе RC2 указывала на управление пирами — то есть на
|
||||
// подсистему, не имеющую к отказу никакого отношения. Самая ценная дверь
|
||||
// продукта обязана называться своим именем в стеке.
|
||||
func Login(c *gin.Context) {
|
||||
loginDto, err := validateField(c, dto.LoginDto{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
token, forcePasswordChange, err := service.Login(*loginDto.Username, *loginDto.Pass)
|
||||
if err != nil {
|
||||
// Неверные учётные данные получают код, чтобы панель показала
|
||||
// оператору внятную фразу на его языке. Отказ базы остаётся системной
|
||||
// ошибкой: выдавать «неверный логин или пароль» при недоступной SQLite
|
||||
// значит отправить оператора искать несуществующую опечатку.
|
||||
if errors.Is(err, service.ErrInvalidCredentials) {
|
||||
vo.FailDomain(constant.ErrCodeInvalidCredentials, err.Error(), c)
|
||||
return
|
||||
}
|
||||
vo.Fail(err.Error(), c)
|
||||
return
|
||||
}
|
||||
vo.Success(vo.JwtVo{TokenType: constant.TokenType, AccessToken: token, ForcePasswordChange: forcePasswordChange}, c)
|
||||
}
|
||||
@@ -0,0 +1,615 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"hy2xs-admin/dao"
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/model/dto"
|
||||
"hy2xs-admin/model/entity"
|
||||
"hy2xs-admin/model/vo"
|
||||
"hy2xs-admin/service"
|
||||
"hy2xs-admin/util"
|
||||
)
|
||||
|
||||
// Контракт входа в панель.
|
||||
//
|
||||
// Что здесь произошло и почему тестов было мало. В RC2 каждый
|
||||
// `POST /api/auth/login` заканчивался HTTP 500: на LoginDto.Username стоял тег
|
||||
// `validateStr`, правило с таким именем не регистрировалось, и
|
||||
// go-playground/validator ПАНИКОВАЛ до всякой проверки логина и пароля.
|
||||
// Существующие тесты этого не видели, потому что проверяли регексп напрямую —
|
||||
// в обход production-валидатора, — а ни одного запроса к обработчику входа не
|
||||
// делал никто.
|
||||
//
|
||||
// Поэтому проверок здесь три уровня, и каждый ловит свой класс:
|
||||
//
|
||||
// структурный — ни один тег ни в одном DTO не ссылается на незарегистрированное
|
||||
// правило (закрывает КЛАСС, а не найденный экземпляр);
|
||||
// правила — LoginDto проходит через тот самый validate, что и в бою;
|
||||
// HTTP — обработчик отвечает конвертом, а не 500, на любом входе.
|
||||
|
||||
// --------------------------------------------------------------- структура ---
|
||||
|
||||
var validationTagPattern = regexp.MustCompile(`validate:"([^"]*)"`)
|
||||
|
||||
// Ни один тег валидации не имеет права ссылаться на правило, которого нет.
|
||||
//
|
||||
// Это главный барьер файла. Наивная версия — «прогнать нулевое DTO через
|
||||
// validate.Struct» — дефекта RC2 НЕ ловит: цепочка правил поля обрывается на
|
||||
// первом несработавшем, поэтому на пустом Username проверка отказывает по
|
||||
// `required` и до испорченного тега не доходит. Поэтому теги извлекаются из
|
||||
// исходников и каждый предъявляется валидатору отдельно: незарегистрированное
|
||||
// правило паникует ровно так же, как паниковало в бою, но здесь — на сборке.
|
||||
func TestEveryValidationTagIsRegistered(t *testing.T) {
|
||||
root := filepath.Join("..", "model")
|
||||
|
||||
users := map[string]map[string]struct{}{}
|
||||
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") {
|
||||
return nil
|
||||
}
|
||||
source, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
for _, match := range validationTagPattern.FindAllSubmatch(source, -1) {
|
||||
for _, rule := range splitValidationRules(string(match[1])) {
|
||||
if users[rule] == nil {
|
||||
users[rule] = map[string]struct{}{}
|
||||
}
|
||||
users[rule][filepath.ToSlash(path)] = struct{}{}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось прочитать исходники модели: %v", err)
|
||||
}
|
||||
|
||||
// Пустой обход означал бы, что проверка молчит из-за неверного пути, а не
|
||||
// из-за отсутствия дефектов. Число намеренно грубое: тест закрепляет факт
|
||||
// «теги найдены», а не их количество.
|
||||
if len(users) < 5 {
|
||||
t.Fatalf("в %s найдено %d правил валидации — обход не дошёл до DTO", root, len(users))
|
||||
}
|
||||
|
||||
for rule, files := range users {
|
||||
assertRuleIsRegistered(t, rule, files)
|
||||
}
|
||||
}
|
||||
|
||||
// splitValidationRules разбирает содержимое тега на отдельные правила.
|
||||
//
|
||||
// Разделителями считаются и `,`, и `|`: go-playground/validator понимает оба —
|
||||
// первый как «и», второй как «или», — и правило, записанное через `|`, обязано
|
||||
// существовать ровно так же.
|
||||
func splitValidationRules(tag string) []string {
|
||||
var rules []string
|
||||
for _, rule := range strings.FieldsFunc(tag, func(r rune) bool { return r == ',' || r == '|' }) {
|
||||
rule = strings.TrimSpace(rule)
|
||||
if rule == "" || rule == "-" {
|
||||
continue
|
||||
}
|
||||
rules = append(rules, rule)
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
func assertRuleIsRegistered(t *testing.T, rule string, files map[string]struct{}) {
|
||||
t.Helper()
|
||||
|
||||
where := make([]string, 0, len(files))
|
||||
for file := range files {
|
||||
where = append(where, file)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
t.Errorf(
|
||||
"правило %q не зарегистрировано в controller/validator.go, но используется в %s: %v\n"+
|
||||
"незарегистрированный тег означает панику на КАЖДОМ запросе к этой форме",
|
||||
rule, strings.Join(where, ", "), recovered,
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
// Значение подобрано так, чтобы вопрос был именно о существовании правила:
|
||||
// результат проверки здесь не важен, важно её отсутствие паники.
|
||||
_ = validate.Var("hy2xsadmin", rule)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ правила ---
|
||||
|
||||
func loginDto(username string, password string) dto.LoginDto {
|
||||
return dto.LoginDto{Username: &username, Pass: &password}
|
||||
}
|
||||
|
||||
// normalizedAndValidated повторяет конвейер validateField: нормализация, затем
|
||||
// правила. Проверять правила в обход нормализации бессмысленно — в бою они
|
||||
// видят только приведённый вход.
|
||||
func normalizedAndValidated(login dto.LoginDto) error {
|
||||
login.Normalize()
|
||||
return validate.Struct(&login)
|
||||
}
|
||||
|
||||
// Регрессия RC2: корректный вход не имеет права ни паниковать, ни отказывать.
|
||||
func TestLoginDtoPassesProductionValidator(t *testing.T) {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
t.Fatalf("проверка LoginDto паникует: %v", recovered)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := normalizedAndValidated(loginDto("hy2xsadmin", "bootstrap-password")); err != nil {
|
||||
t.Fatalf("корректный LoginDto отклонён: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Отказ обязан быть ОТКАЗОМ — значением с причиной, а не паникой.
|
||||
func TestLoginDtoReportsReasonInsteadOfPanic(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
username string
|
||||
password string
|
||||
code string
|
||||
field string
|
||||
}{
|
||||
{"кириллица в логине", "админ12", "bootstrap-password", constant.ErrCodeCredentialStr, "username"},
|
||||
{"пробел в логине", "hy2xs admin", "bootstrap-password", constant.ErrCodeCredentialStr, "username"},
|
||||
{"логин короче минимума", "admin", "bootstrap-password", constant.ErrCodeCredentialStr, "username"},
|
||||
{"логин длиннее максимума", strings.Repeat("a", service.AdminUsernameMaxLength+1), "bootstrap-password", constant.ErrCodeCredentialStr, "username"},
|
||||
{"пустой логин", " ", "bootstrap-password", constant.ErrCodeRequired, "username"},
|
||||
{"пустой пароль", "hy2xsadmin", "", constant.ErrCodeRequired, "pass"},
|
||||
{"пароль короче минимума", "hy2xsadmin", "short", constant.ErrCodeMinLength, "pass"},
|
||||
{"пароль длиннее максимума", "hy2xsadmin", strings.Repeat("a", service.AdminPasswordMaxLength+1), constant.ErrCodeMaxLength, "pass"},
|
||||
}
|
||||
|
||||
for _, testCase := range cases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
t.Fatalf("проверка обязана вернуть отказ, а не паниковать: %v", recovered)
|
||||
}
|
||||
}()
|
||||
|
||||
err := normalizedAndValidated(loginDto(testCase.username, testCase.password))
|
||||
if err == nil {
|
||||
t.Fatal("некорректный вход принят")
|
||||
}
|
||||
|
||||
described := describeValidationErrors(err)
|
||||
if !hasFieldReason(described, testCase.field, testCase.code) {
|
||||
t.Fatalf("ожидалась причина %q на поле %q, получено %+v", testCase.code, testCase.field, described)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func hasFieldReason(described []vo.FieldError, field string, code string) bool {
|
||||
for _, reason := range described {
|
||||
if reason.Field == field && reason.Code == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Причина отказа по набору символов обязана НАЗЫВАТЬ набор.
|
||||
//
|
||||
// Оператору негде посмотреть, какие символы допустимы в логине: значение
|
||||
// приходит из hy2xs.env, а панель показывает то, что ответил сервер. Прежнее
|
||||
// «содержит недопустимые символы» вдобавок описывало и отказ по длине.
|
||||
func TestCredentialReasonNamesCharsetAndBounds(t *testing.T) {
|
||||
err := normalizedAndValidated(loginDto("админ12", "bootstrap-password"))
|
||||
if err == nil {
|
||||
t.Fatal("некорректный логин принят")
|
||||
}
|
||||
|
||||
for _, reason := range describeValidationErrors(err) {
|
||||
if reason.Code != constant.ErrCodeCredentialStr {
|
||||
continue
|
||||
}
|
||||
if reason.Params["charset"] != service.AdminUsernameCharset {
|
||||
t.Errorf("причина не называет набор символов: %+v", reason.Params)
|
||||
}
|
||||
if reason.Params["min"] != strconv.Itoa(service.AdminUsernameMinLength) {
|
||||
t.Errorf("причина не называет нижнюю границу: %+v", reason.Params)
|
||||
}
|
||||
if reason.Params["max"] != strconv.Itoa(service.AdminUsernameMaxLength) {
|
||||
t.Errorf("причина не называет верхнюю границу: %+v", reason.Params)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatalf("отказ не содержит причины %q", constant.ErrCodeCredentialStr)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- контракты ---
|
||||
|
||||
// Границы пароля объявлены в трёх местах и обязаны совпадать.
|
||||
//
|
||||
// Пока они расходились, панель запирала оператора снаружи: форма смены пароля
|
||||
// принимала 64 символа, форма входа — 32. Тег структуры не умеет ссылаться на
|
||||
// константу, поэтому равенство удерживается здесь.
|
||||
func TestPasswordBoundsAreOneContract(t *testing.T) {
|
||||
for _, subject := range []struct {
|
||||
name string
|
||||
typ reflect.Type
|
||||
field string
|
||||
}{
|
||||
{"LoginDto.Pass", reflect.TypeOf(dto.LoginDto{}), "Pass"},
|
||||
{"AdminChangePasswordDto.OldPassword", reflect.TypeOf(dto.AdminChangePasswordDto{}), "OldPassword"},
|
||||
{"AdminChangePasswordDto.NewPassword", reflect.TypeOf(dto.AdminChangePasswordDto{}), "NewPassword"},
|
||||
} {
|
||||
field, ok := subject.typ.FieldByName(subject.field)
|
||||
if !ok {
|
||||
t.Fatalf("%s: поле не найдено", subject.name)
|
||||
}
|
||||
|
||||
bounds := map[string]int{}
|
||||
for _, rule := range splitValidationRules(field.Tag.Get("validate")) {
|
||||
name, param, found := strings.Cut(rule, "=")
|
||||
if !found || (name != "min" && name != "max") {
|
||||
continue
|
||||
}
|
||||
value, err := strconv.Atoi(param)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: граница %q не число", subject.name, rule)
|
||||
}
|
||||
bounds[name] = value
|
||||
}
|
||||
|
||||
if bounds["min"] != service.AdminPasswordMinLength {
|
||||
t.Errorf("%s: min=%d, а контракт — %d", subject.name, bounds["min"], service.AdminPasswordMinLength)
|
||||
}
|
||||
if bounds["max"] != service.AdminPasswordMaxLength {
|
||||
t.Errorf("%s: max=%d, а контракт — %d", subject.name, bounds["max"], service.AdminPasswordMaxLength)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Набор символов пароля сервером НЕ ограничивается — ни на входе, ни при смене.
|
||||
//
|
||||
// Это решение, а не упущение: значение всё равно сравнивается с хешем, поэтому
|
||||
// ограничение набора не защищает ничего и умеет только отвергнуть пароль,
|
||||
// который сервер принял бы. Панель обязана держаться этого же контракта, и
|
||||
// проверка существует, чтобы правило не «навели» обратно.
|
||||
func TestPasswordHasNoCharsetRule(t *testing.T) {
|
||||
for _, subject := range []struct {
|
||||
name string
|
||||
typ reflect.Type
|
||||
field string
|
||||
}{
|
||||
{"LoginDto.Pass", reflect.TypeOf(dto.LoginDto{}), "Pass"},
|
||||
{"AdminChangePasswordDto.OldPassword", reflect.TypeOf(dto.AdminChangePasswordDto{}), "OldPassword"},
|
||||
{"AdminChangePasswordDto.NewPassword", reflect.TypeOf(dto.AdminChangePasswordDto{}), "NewPassword"},
|
||||
} {
|
||||
field, _ := subject.typ.FieldByName(subject.field)
|
||||
for _, rule := range splitValidationRules(field.Tag.Get("validate")) {
|
||||
if rule == "credentialStr" || rule == "peerName" {
|
||||
t.Errorf("%s: на пароль повешено правило набора символов %q", subject.name, rule)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Длина логина живёт ВНУТРИ правила, а не рядом с ним.
|
||||
//
|
||||
// Два правила длины на одном поле уже приводили к отказу, который невозможно
|
||||
// объяснить оператору: `min=1,max=32` вместе с правилом «6-32» принимали и
|
||||
// отвергали одно и то же значение, а панель показывала «invalid».
|
||||
func TestUsernameLengthLivesInsideItsRule(t *testing.T) {
|
||||
field, ok := reflect.TypeOf(dto.LoginDto{}).FieldByName("Username")
|
||||
if !ok {
|
||||
t.Fatal("LoginDto.Username не найдено")
|
||||
}
|
||||
|
||||
rules := splitValidationRules(field.Tag.Get("validate"))
|
||||
for _, rule := range rules {
|
||||
if strings.HasPrefix(rule, "min=") || strings.HasPrefix(rule, "max=") {
|
||||
t.Errorf("на логине отдельная граница %q: длина принадлежит правилу credentialStr", rule)
|
||||
}
|
||||
}
|
||||
|
||||
var hasCredentialRule bool
|
||||
for _, rule := range rules {
|
||||
if rule == "credentialStr" {
|
||||
hasCredentialRule = true
|
||||
}
|
||||
}
|
||||
if !hasCredentialRule {
|
||||
t.Fatalf("логин проверяется правилами %v — среди них нет credentialStr", rules)
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------- HTTP ---
|
||||
|
||||
func newAuthTestDB(t *testing.T, username string, password string) int64 {
|
||||
t.Helper()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db")
|
||||
if err := dao.InitSqliteDBAt(dbPath); err != nil {
|
||||
t.Fatalf("не удалось открыть тестовую базу: %v", err)
|
||||
}
|
||||
if err := dao.RunMigrations(); err != nil {
|
||||
t.Fatalf("не удалось применить миграции: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = dao.CloseSqliteDB() })
|
||||
|
||||
// Токен подписывается ключом из таблицы config. Без него успешный вход
|
||||
// отказал бы системной ошибкой, и тест перестал бы отличать работающий
|
||||
// вход от неработающего.
|
||||
if err := dao.UpsertConfigValue(constant.JwtSecret, "test-jwt-secret"); err != nil {
|
||||
t.Fatalf("не удалось записать ключ подписи: %v", err)
|
||||
}
|
||||
|
||||
hash, err := util.HashPassword(password)
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось захешировать пароль: %v", err)
|
||||
}
|
||||
|
||||
name := username
|
||||
status := int64(1)
|
||||
tokenVersion := int64(1)
|
||||
force := int64(0)
|
||||
changedAt := time.Now().UnixMilli()
|
||||
id, err := dao.SaveAdminUser(entity.AdminUser{
|
||||
Username: &name,
|
||||
PasswordHash: &hash,
|
||||
Status: &status,
|
||||
TokenVersion: &tokenVersion,
|
||||
ForcePasswordChange: &force,
|
||||
PasswordChangedAt: &changedAt,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось создать администратора: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// postLoginRaw отправляет тело КАК ЕСТЬ через маршрут боевой конфигурации.
|
||||
//
|
||||
// `recovery` выбирает, стоит ли перед обработчиком gin.Recovery. Обе ветки
|
||||
// нужны: без него паника роняет тест и называет причину, с ним воспроизводится
|
||||
// ровно то, что видел оператор RC2, — HTTP 500 вместо конверта.
|
||||
func postLoginRaw(t *testing.T, body []byte, recovery bool) (int, apiResult) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
engine := gin.New()
|
||||
if recovery {
|
||||
engine.Use(gin.Recovery())
|
||||
}
|
||||
engine.POST("/api/auth/login", Login)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewReader(body))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, request)
|
||||
|
||||
var result apiResult
|
||||
if recorder.Body.Len() > 0 {
|
||||
_ = json.Unmarshal(recorder.Body.Bytes(), &result)
|
||||
}
|
||||
return recorder.Code, result
|
||||
}
|
||||
|
||||
func postLogin(t *testing.T, body any) (int, apiResult) {
|
||||
t.Helper()
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось собрать тело запроса: %v", err)
|
||||
}
|
||||
return postLoginRaw(t, payload, false)
|
||||
}
|
||||
|
||||
// Регрессия RC2 целиком: вход bootstrap-учёткой обязан выдать токен.
|
||||
func TestLoginEndpointIssuesTokenForValidCredentials(t *testing.T) {
|
||||
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
|
||||
|
||||
status, result := postLogin(t, map[string]any{
|
||||
"username": "hy2xsadmin",
|
||||
"pass": "bootstrap-password",
|
||||
})
|
||||
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("вход ответил HTTP %d, ожидался 200", status)
|
||||
}
|
||||
if result.Code != constant.CodeSuccess {
|
||||
t.Fatalf("вход отклонён: %+v", result)
|
||||
}
|
||||
|
||||
var issued struct {
|
||||
TokenType string `json:"tokenType"`
|
||||
AccessToken string `json:"accessToken"`
|
||||
}
|
||||
if err := json.Unmarshal(result.Data, &issued); err != nil {
|
||||
t.Fatalf("ответ не содержит токена: %s", string(result.Data))
|
||||
}
|
||||
if strings.TrimSpace(issued.AccessToken) == "" {
|
||||
t.Fatal("выдан пустой токен")
|
||||
}
|
||||
if issued.TokenType != constant.TokenType {
|
||||
t.Fatalf("тип токена %q, ожидался %q", issued.TokenType, constant.TokenType)
|
||||
}
|
||||
}
|
||||
|
||||
// Тот же запрос ЗА gin.Recovery: именно так он шёл в бою.
|
||||
//
|
||||
// В RC2 здесь был HTTP 500 — панику валидатора Recovery превращал в системную
|
||||
// ошибку транспорта, и панель не могла сказать о ней ничего осмысленного.
|
||||
func TestLoginEndpointNeverAnswersServerErrorBehindRecovery(t *testing.T) {
|
||||
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
|
||||
|
||||
bodies := [][]byte{
|
||||
[]byte(`{"username":"hy2xsadmin","pass":"bootstrap-password"}`),
|
||||
[]byte(`{"username":"","pass":""}`),
|
||||
[]byte(`{"username":"админ12","pass":"bootstrap-password"}`),
|
||||
[]byte(`{"username":"hy2xsadmin"}`),
|
||||
[]byte(`{}`),
|
||||
[]byte(`{"username":123,"pass":false}`),
|
||||
[]byte(`не json вовсе`),
|
||||
[]byte(``),
|
||||
}
|
||||
|
||||
for _, body := range bodies {
|
||||
status, result := postLoginRaw(t, body, true)
|
||||
if status != http.StatusOK {
|
||||
t.Errorf("тело %q дало HTTP %d: обработчик обязан отвечать конвертом, а не отказом транспорта", string(body), status)
|
||||
continue
|
||||
}
|
||||
if result.Code != constant.CodeSuccess && len(result.Errors) == 0 && strings.TrimSpace(result.Message) == "" {
|
||||
t.Errorf("тело %q дало отказ без причины: %+v", string(body), result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Неверный пароль — доменный отказ с кодом, а не системная ошибка.
|
||||
func TestLoginEndpointReportsInvalidCredentials(t *testing.T) {
|
||||
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
|
||||
|
||||
_, result := postLogin(t, map[string]any{
|
||||
"username": "hy2xsadmin",
|
||||
"pass": "wrong-password",
|
||||
})
|
||||
|
||||
if result.Code == constant.CodeSuccess {
|
||||
t.Fatal("вход с неверным паролем выполнен")
|
||||
}
|
||||
if _, found := errorFor(t, result, ""); !found {
|
||||
t.Fatalf("отказ без причины: %+v", result)
|
||||
}
|
||||
var hasCode bool
|
||||
for _, reason := range result.Errors {
|
||||
if reason.Code == constant.ErrCodeInvalidCredentials {
|
||||
hasCode = true
|
||||
}
|
||||
}
|
||||
if !hasCode {
|
||||
t.Fatalf("отказ не помечен кодом %q: %+v", constant.ErrCodeInvalidCredentials, result)
|
||||
}
|
||||
}
|
||||
|
||||
// Несуществующий администратор неотличим от неверного пароля.
|
||||
//
|
||||
// Иначе форма входа превращается в способ проверять существование имён.
|
||||
func TestLoginEndpointDoesNotRevealWhetherAccountExists(t *testing.T) {
|
||||
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
|
||||
|
||||
_, missing := postLogin(t, map[string]any{"username": "otheradmin", "pass": "bootstrap-password"})
|
||||
_, wrong := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": "wrong-password"})
|
||||
|
||||
if missing.Code != wrong.Code || missing.Message != wrong.Message {
|
||||
t.Fatalf("ответы различимы: несуществующий %+v против неверного пароля %+v", missing, wrong)
|
||||
}
|
||||
}
|
||||
|
||||
// Логин из bootstrap-admin.secret регулярно приезжает с пробелом или переводом
|
||||
// строки на конце. Раньше это давало «неверный логин или пароль».
|
||||
func TestLoginEndpointTrimsSurroundingWhitespaceInUsername(t *testing.T) {
|
||||
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
|
||||
|
||||
_, result := postLogin(t, map[string]any{
|
||||
"username": " hy2xsadmin\n",
|
||||
"pass": "bootstrap-password",
|
||||
})
|
||||
|
||||
if result.Code != constant.CodeSuccess {
|
||||
t.Fatalf("логин с окружающими пробелами отклонён: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// Пароль НЕ триммится: его набор символов не ограничен, и пробел по краям —
|
||||
// часть значения, а не мусор ввода.
|
||||
func TestLoginEndpointDoesNotTrimPassword(t *testing.T) {
|
||||
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password ")
|
||||
|
||||
_, exact := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": "bootstrap-password "})
|
||||
if exact.Code != constant.CodeSuccess {
|
||||
t.Fatalf("пароль с пробелом на конце отклонён: %+v", exact)
|
||||
}
|
||||
|
||||
_, trimmed := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": "bootstrap-password"})
|
||||
if trimmed.Code == constant.CodeSuccess {
|
||||
t.Fatal("пароль обрезан по краям: вход выполнен не тем значением, которое ввёл оператор")
|
||||
}
|
||||
}
|
||||
|
||||
// Пароль длиной в 64 символа, назначенный формой смены, обязан пускать в панель.
|
||||
//
|
||||
// Ровно этот путь был закрыт: форма входа ограничивала пароль 32 символами при
|
||||
// серверном пределе в 64, и оператор терял доступ после штатной смены пароля.
|
||||
func TestLoginEndpointAcceptsFullLengthPassword(t *testing.T) {
|
||||
password := strings.Repeat("a", service.AdminPasswordMaxLength)
|
||||
newAuthTestDB(t, "hy2xsadmin", password)
|
||||
|
||||
_, result := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": password})
|
||||
if result.Code != constant.CodeSuccess {
|
||||
t.Fatalf("пароль предельной длины отклонён: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// Отметка о входе обязана писаться: колонка есть в схеме, а писать её было
|
||||
// некому — UpdateAdminLastLoginAt не вызывался ниоткуда.
|
||||
func TestLoginEndpointRecordsLastLoginAt(t *testing.T) {
|
||||
id := newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
|
||||
|
||||
before, err := dao.GetAdminUser("id = ?", id)
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось прочитать администратора: %v", err)
|
||||
}
|
||||
if before.LastLoginAt != nil && *before.LastLoginAt != 0 {
|
||||
t.Fatalf("подготовка: отметка о входе уже проставлена (%d)", *before.LastLoginAt)
|
||||
}
|
||||
|
||||
if _, result := postLogin(t, map[string]any{
|
||||
"username": "hy2xsadmin",
|
||||
"pass": "bootstrap-password",
|
||||
}); result.Code != constant.CodeSuccess {
|
||||
t.Fatalf("вход отклонён: %+v", result)
|
||||
}
|
||||
|
||||
after, err := dao.GetAdminUser("id = ?", id)
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось перечитать администратора: %v", err)
|
||||
}
|
||||
if after.LastLoginAt == nil || *after.LastLoginAt == 0 {
|
||||
t.Fatal("время входа не записано")
|
||||
}
|
||||
}
|
||||
|
||||
// Неудачная попытка отметку о входе НЕ ставит.
|
||||
func TestFailedLoginDoesNotRecordLastLoginAt(t *testing.T) {
|
||||
id := newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
|
||||
|
||||
if _, result := postLogin(t, map[string]any{
|
||||
"username": "hy2xsadmin",
|
||||
"pass": "wrong-password",
|
||||
}); result.Code == constant.CodeSuccess {
|
||||
t.Fatal("вход с неверным паролем выполнен")
|
||||
}
|
||||
|
||||
after, err := dao.GetAdminUser("id = ?", id)
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось прочитать администратора: %v", err)
|
||||
}
|
||||
if after.LastLoginAt != nil && *after.LastLoginAt != 0 {
|
||||
t.Fatalf("неудачная попытка проставила время входа: %d", *after.LastLoginAt)
|
||||
}
|
||||
}
|
||||
@@ -46,27 +46,6 @@ func resolveID(c *gin.Context) (int64, error) {
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func Login(c *gin.Context) {
|
||||
loginDto, err := validateField(c, dto.LoginDto{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
token, forcePasswordChange, err := service.Login(*loginDto.Username, *loginDto.Pass)
|
||||
if err != nil {
|
||||
// Неверные учётные данные получают код, чтобы панель показала
|
||||
// оператору внятную фразу на его языке. Отказ базы остаётся системной
|
||||
// ошибкой: выдавать «неверный логин или пароль» при недоступной SQLite
|
||||
// значит отправить оператора искать несуществующую опечатку.
|
||||
if errors.Is(err, service.ErrInvalidCredentials) {
|
||||
vo.FailDomain(constant.ErrCodeInvalidCredentials, err.Error(), c)
|
||||
return
|
||||
}
|
||||
vo.Fail(err.Error(), c)
|
||||
return
|
||||
}
|
||||
vo.Success(vo.JwtVo{TokenType: constant.TokenType, AccessToken: token, ForcePasswordChange: forcePasswordChange}, c)
|
||||
}
|
||||
|
||||
func PagePeer(c *gin.Context) {
|
||||
peerPageDto, err := validateField(c, dto.PeerPageDto{})
|
||||
if err != nil {
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -60,21 +59,22 @@ func validatePeerName(f validator.FieldLevel) bool {
|
||||
return service.IsValidPeerName(f.Field().String())
|
||||
}
|
||||
|
||||
// credentialStrPattern — набор символов логина и пароля администратора.
|
||||
// validateCredentialStr — единственное правило имени администратора.
|
||||
//
|
||||
// Класс записан ЯВНО и повторяет прежнее ФАКТИЧЕСКОЕ множество, включая
|
||||
// последствия неэкранированного дефиса в исходной записи `_+-=`. Это сделано
|
||||
// намеренно: имя администратора приходит из HY2XS_ADMIN_USER в hy2xs.env,
|
||||
// оркестратор набор символов не ограничивает, и сужение правила означало бы,
|
||||
// что установка с логином вроде `admin.ops` перестаёт пускать оператора в
|
||||
// панель. Сужать этот набор можно только вместе с проверкой имени на стороне
|
||||
// оркестратора, и это отдельная работа, а не побочный эффект правки формы
|
||||
// пира.
|
||||
var credentialStrPattern = regexp.MustCompile(`^[a-zA-Z0-9!@#$%^&*()_+,\-./:;<=]{6,32}$`)
|
||||
|
||||
// Набор символов и длина берутся из service по той же причине, что и у имени
|
||||
// пира: собственная копия правила в слое контроллеров уже расходилась с
|
||||
// остальным продуктом. Здесь она вдобавок была единственным местом, знавшим
|
||||
// набор, — оркестратор писал в hy2xs.env любое имя, а панель проверяла своё
|
||||
// третье правило.
|
||||
//
|
||||
// Пустое значение НЕ впускается. Раньше здесь стояло `field == "" || …`, и это
|
||||
// была ловушка: у поля-указателя `required` считает непустым сам факт
|
||||
// ненулевого указателя, поэтому `{"username":""}` доходило сюда и объявлялось
|
||||
// корректным. Пустой логин отсекается нормализацией DTO, которая превращает
|
||||
// его в «не задано», и получает отказ `required` — то есть ту причину, которая
|
||||
// у него на самом деле.
|
||||
func validateCredentialStr(f validator.FieldLevel) bool {
|
||||
field := f.Field().String()
|
||||
return field == "" || credentialStrPattern.MatchString(field)
|
||||
return service.IsValidAdminUsername(f.Field().String())
|
||||
}
|
||||
|
||||
// validateField разбирает запрос, приводит его к каноничному виду и проверяет
|
||||
@@ -202,8 +202,20 @@ func describeFieldError(fieldErr validator.FieldError) vo.FieldError {
|
||||
service.PeerNameMinLength, service.PeerNameMaxLength, service.PeerNameCharset,
|
||||
)
|
||||
case "credentialStr":
|
||||
// Причина называет набор и границы ровно так же, как это делает
|
||||
// `peerName`. Прежнее «содержит недопустимые символы» не отвечало на
|
||||
// вопрос оператора: набор символов логина ему негде посмотреть, а
|
||||
// отказ по длине этой фразой описывался как отказ по символам.
|
||||
described.Code = constant.ErrCodeCredentialStr
|
||||
described.Message = fmt.Sprintf("поле %q содержит недопустимые символы", field)
|
||||
described.Params = map[string]string{
|
||||
"min": fmt.Sprintf("%d", service.AdminUsernameMinLength),
|
||||
"max": fmt.Sprintf("%d", service.AdminUsernameMaxLength),
|
||||
"charset": service.AdminUsernameCharset,
|
||||
}
|
||||
described.Message = fmt.Sprintf(
|
||||
"поле %q: от %d до %d символов из набора %s",
|
||||
field, service.AdminUsernameMinLength, service.AdminUsernameMaxLength, service.AdminUsernameCharset,
|
||||
)
|
||||
default:
|
||||
described.Code = constant.ErrCodeRuleUnknown
|
||||
described.Params = map[string]string{"rule": fieldErr.Tag()}
|
||||
|
||||
@@ -7,41 +7,96 @@ import (
|
||||
"hy2xs-admin/service"
|
||||
)
|
||||
|
||||
// Набор символов логина и пароля закреплён ФАКТИЧЕСКИМ множеством.
|
||||
// Набор символов логина администратора закреплён ФАКТИЧЕСКИМ множеством.
|
||||
//
|
||||
// Прежняя запись класса `[a-zA-Z0-9!@#$%^&*()_+-=]` содержала неэкранированный
|
||||
// дефис, из-за чего `+-=` образовывал диапазон и впускал `, - . / 0-9 : ; < =`.
|
||||
// Новая запись перечисляет эти символы явно и НЕ сужает множество: имя
|
||||
// администратора приходит из HY2XS_ADMIN_USER в hy2xs.env, оркестратор его
|
||||
// набор символов не ограничивает, и сужение правила означало бы, что установка
|
||||
// с логином вроде `admin.ops` перестаёт пускать оператора в панель.
|
||||
// Действующая запись перечисляет эти символы явно и НЕ сужает множество: имя
|
||||
// администратора приходит из HY2XS_ADMIN_USER в hy2xs.env, и сужение правила
|
||||
// означало бы, что установка с логином вроде `admin.ops` перестаёт пускать
|
||||
// оператора в панель.
|
||||
//
|
||||
// Тест существует, чтобы это решение было явным: попытка «навести порядок» в
|
||||
// классе символов уронит его, а не вход администратора на живом сервере.
|
||||
func TestCredentialCharsetIsUnchanged(t *testing.T) {
|
||||
//
|
||||
// Спрашивается service.IsValidAdminUsername, а не регексп рядом: правило
|
||||
// объявлено там один раз, и проверка обязана спрашивать ровно то, что работает
|
||||
// в бою. Прежняя версия обращалась к копии внутри слоя контроллеров — то есть
|
||||
// не увидела бы расхождения между копией и правилом.
|
||||
func TestAdminUsernameCharsetIsUnchanged(t *testing.T) {
|
||||
const historical = "abcXYZ019" + "!@#$%^&*()_" + "+,-./:;<="
|
||||
|
||||
for _, symbol := range strings.Split(historical, "") {
|
||||
candidate := "admin" + symbol
|
||||
if !credentialStrPattern.MatchString(candidate) {
|
||||
if !service.IsValidAdminUsername(candidate) {
|
||||
t.Errorf("символ %q больше не принимается логином: сужение набора ломает вход существующей установки", symbol)
|
||||
}
|
||||
}
|
||||
|
||||
for _, rejected := range []string{
|
||||
"admi", // короче шести символов
|
||||
strings.Repeat("a", 33), // длиннее тридцати двух
|
||||
"admin пробел", // пробел
|
||||
"админ1", // кириллица
|
||||
"admin\n1", // перевод строки
|
||||
"admin'1", // апостроф вне набора
|
||||
"", // не задано
|
||||
"admi", // короче шести символов
|
||||
strings.Repeat("a", 33), // длиннее тридцати двух
|
||||
"admin пробел", // пробел
|
||||
"админ1", // кириллица
|
||||
"admin\n1", // перевод строки
|
||||
"admin'1", // апостроф вне набора
|
||||
} {
|
||||
if credentialStrPattern.MatchString(rejected) {
|
||||
if service.IsValidAdminUsername(rejected) {
|
||||
t.Errorf("значение %q принято логином, ожидался отказ", rejected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Границы длины логина принадлежат ОДНОМУ правилу, и человекочитаемый набор
|
||||
// обязан описывать тот же класс, что и регексп.
|
||||
//
|
||||
// Расхождение здесь тихо превращает сообщение об отказе в дезинформацию:
|
||||
// оператор читает набор, которого правило не принимает.
|
||||
func TestAdminUsernameBoundsAgreeWithRule(t *testing.T) {
|
||||
if service.IsValidAdminUsername(strings.Repeat("a", service.AdminUsernameMinLength-1)) {
|
||||
t.Errorf("логин длиной %d принят при минимуме %d", service.AdminUsernameMinLength-1, service.AdminUsernameMinLength)
|
||||
}
|
||||
if !service.IsValidAdminUsername(strings.Repeat("a", service.AdminUsernameMinLength)) {
|
||||
t.Errorf("логин длиной ровно %d отклонён", service.AdminUsernameMinLength)
|
||||
}
|
||||
if !service.IsValidAdminUsername(strings.Repeat("a", service.AdminUsernameMaxLength)) {
|
||||
t.Errorf("логин длиной ровно %d отклонён", service.AdminUsernameMaxLength)
|
||||
}
|
||||
if service.IsValidAdminUsername(strings.Repeat("a", service.AdminUsernameMaxLength+1)) {
|
||||
t.Errorf("логин длиной %d принят при максимуме %d", service.AdminUsernameMaxLength+1, service.AdminUsernameMaxLength)
|
||||
}
|
||||
|
||||
// Человекочитаемый набор перечисляет ровно те же знаки препинания, что и
|
||||
// класс регекспа. Пробелы в нём — разделители групп, а не допустимый символ.
|
||||
punctuation := strings.Fields(service.AdminUsernameCharset)
|
||||
for _, symbol := range strings.Split(punctuation[len(punctuation)-1], "") {
|
||||
if !service.IsValidAdminUsername("admin" + symbol) {
|
||||
t.Errorf("набор из сообщения обещает символ %q, но правило его не принимает", symbol)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Длина пароля считается в РУНАХ — ровно так её считает go-playground/validator
|
||||
// в правилах `min`/`max` на строке.
|
||||
//
|
||||
// Расхождение означало бы, что пароль с кириллицей проходит проверку на одной
|
||||
// двери и отказывается на другой: 32 кириллические буквы — это 64 БАЙТА.
|
||||
func TestAdminPasswordLengthIsCountedInRunes(t *testing.T) {
|
||||
if !service.IsValidAdminPassword(strings.Repeat("я", service.AdminPasswordMaxLength)) {
|
||||
t.Errorf("пароль из %d кириллических букв отклонён: длина считается в байтах", service.AdminPasswordMaxLength)
|
||||
}
|
||||
if service.IsValidAdminPassword(strings.Repeat("я", service.AdminPasswordMaxLength+1)) {
|
||||
t.Error("пароль длиннее максимума принят")
|
||||
}
|
||||
if service.IsValidAdminPassword(strings.Repeat("a", service.AdminPasswordMinLength-1)) {
|
||||
t.Error("пароль короче минимума принят")
|
||||
}
|
||||
if !service.IsValidAdminPassword(strings.Repeat("a", service.AdminPasswordMinLength)) {
|
||||
t.Error("пароль длиной ровно в минимум отклонён")
|
||||
}
|
||||
}
|
||||
|
||||
// Имя пира проверяется ОДНИМ правилом на весь продукт: панель и импорт ведут в
|
||||
// одну таблицу и не имеют права требовать разного.
|
||||
func TestPeerNameRuleIsSharedWithImport(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user