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:
@@ -96,6 +96,82 @@ Hysteria-интеграции с официальной документацие
|
|||||||
никто не проверял. Разбор задокументирован в
|
никто не проверял. Разбор задокументирован в
|
||||||
[docs/acceptance/2026-09-02-v1.0.0-rc4-preflight-findings.md](docs/acceptance/2026-09-02-v1.0.0-rc4-preflight-findings.md).
|
[docs/acceptance/2026-09-02-v1.0.0-rc4-preflight-findings.md](docs/acceptance/2026-09-02-v1.0.0-rc4-preflight-findings.md).
|
||||||
|
|
||||||
|
Четырнадцатый проход — по итогам runtime-прогона `v1.0.0-rc2` на чистом
|
||||||
|
Debian 13. Тема: **разница между «поднялось» и «работает»**. Установка прошла
|
||||||
|
целиком и объявила `INSTALL EXIT CODE: 0`, юнит был активен, порт слушал,
|
||||||
|
`/healthz` отвечал `ok: true` — и вход в панель отдавал HTTP 500 на каждый
|
||||||
|
запрос, потому что валидатор паниковал на теге правила, пережившего
|
||||||
|
переименование. Ни один из трёх признаков готовности не отвечал на вопрос, ради
|
||||||
|
которого они проверялись. Вместе с самим дефектом закрыт его класс: барьер,
|
||||||
|
проверяющий каждый тег валидации на существование, и smoke, выполняющий
|
||||||
|
настоящий вход. Прогон задокументирован в
|
||||||
|
[docs/acceptance/2026-09-04-v1.0.0-rc2-runtime-findings.md](docs/acceptance/2026-09-04-v1.0.0-rc2-runtime-findings.md).
|
||||||
|
|
||||||
|
### Исправлено — вход в панель
|
||||||
|
|
||||||
|
- **`POST /api/auth/login` паниковал до проверки учётных данных.** На
|
||||||
|
`LoginDto.Username` стоял тег `validateStr` — правило с таким именем не
|
||||||
|
регистрировалось: при переименовании в `credentialStr` правка не доехала до
|
||||||
|
одного файла, оставив мёртвую регистрацию и живую ссылку на несуществующее
|
||||||
|
имя. `go-playground/validator` на неизвестный тег ПАНИКУЕТ при разборе
|
||||||
|
структуры, `gin.Recovery` превращал панику в HTTP 500, и панель после чистой
|
||||||
|
установки была недоступна целиком.
|
||||||
|
|
||||||
|
- **Ни один тест не прогонял DTO через production-валидатор.** Проверялся сам
|
||||||
|
регексп, в обход валидатора, а обработчика входа не касался ни один тест.
|
||||||
|
Очевидная замена не помогла бы: цепочка правил поля обрывается на первом
|
||||||
|
несработавшем, поэтому нулевое DTO отказывает по `required` и до испорченного
|
||||||
|
тега не доходит. Теперь `TestEveryValidationTagIsRegistered` извлекает все
|
||||||
|
теги `validate:"…"` из `apps/model/**` и предъявляет каждый валидатору
|
||||||
|
отдельно — закрыт класс, а не найденный экземпляр.
|
||||||
|
|
||||||
|
- **Установка не проверяла, что в панель можно войти.** Smoke отвечал на этот
|
||||||
|
вопрос фактом `LISTEN` и ответом `/healthz`; RC2 доказал, что оба бывают
|
||||||
|
истинны при полностью недоступной панели. Теперь smoke выполняет настоящий
|
||||||
|
`POST /api/auth/login` bootstrap-учётными данными и требует конверт успеха с
|
||||||
|
непустым токеном — по коду HTTP это неотличимо, админка отвечает `200 OK` и
|
||||||
|
на отказ. Отрицательная проба выполняется в любом режиме операции.
|
||||||
|
|
||||||
|
- **Оркестратор не знал контракта, который сам порождает.** `HY2XS_ADMIN_USER`
|
||||||
|
по умолчанию был `admin` — пять символов при минимуме панели в шесть, — и
|
||||||
|
установка с ним завершалась успешно, создавая учётную запись, под которой
|
||||||
|
невозможно войти. Про одно имя существовало три расходящихся умолчания. Оба
|
||||||
|
значения теперь проверяются при разборе окружения, умолчание сведено к
|
||||||
|
`hy2xsadmin` во всех трёх местах.
|
||||||
|
|
||||||
|
- **Панель была строже сервера и запирала оператора после смены пароля.** Форма
|
||||||
|
входа ограничивала пароль 32 символами при серверном пределе в 64, а форма
|
||||||
|
смены пароля назначала до 64: пароль, назначенный штатной операцией, после
|
||||||
|
этого не вводился. Набор символов на пароле отвергал значение, которое сервер
|
||||||
|
принял бы, — сервер его не ограничивает нигде. Контракт учётных данных теперь
|
||||||
|
объявлен один раз в `apps/service/admin_credentials.go`, а копии в панели и
|
||||||
|
оркестраторе сверяются с ним тестами, читающими Go-исходник.
|
||||||
|
|
||||||
|
- **Класс символов логина был записан диапазоном по опечатке.** Неэкранированный
|
||||||
|
дефис в `[a-zA-Z0-9!@#$%^&*()_+-=]` превращал `+-=` в диапазон, впускающий
|
||||||
|
`, - . / 0-9 : ; < =`. С серверным набором это совпадало по совпадению: оба
|
||||||
|
несли одну опечатку. Набор записан явно и НЕ сужен — он уже действует на
|
||||||
|
установленных серверах.
|
||||||
|
|
||||||
|
- **Красная рамка ошибки обводила не то, что видит оператор.** Element Plus
|
||||||
|
рисует состояние отказа на `el-input__wrapper` селектором из четырёх классов,
|
||||||
|
а форма входа рисует видимую рамку поля на `el-form-item` и гасила чужую тень
|
||||||
|
селектором из трёх — и проигрывала по специфичности. Рамка ложилась вокруг
|
||||||
|
одного лишь ввода: у логина начиналась после иконки, у пароля обрывалась перед
|
||||||
|
переключателем видимости. Индикация перенесена на элемент, который оператор и
|
||||||
|
видит полем.
|
||||||
|
|
||||||
|
- **Требование не называлось.** «Неверный формат логина» и «Некорректное
|
||||||
|
значение» не давали оператору ни одного способа узнать, что от него хотят:
|
||||||
|
набор символов логина приходит из `hy2xs.env` и в панели нигде не показан.
|
||||||
|
Фразы форм и серверная причина `credential_format` теперь перечисляют границы
|
||||||
|
и набор.
|
||||||
|
|
||||||
|
- **`last_login_at` не записывался никогда.** Колонка объявлена в схеме и в
|
||||||
|
entity, `UpdateAdminLastLoginAt` существовал и не вызывался ниоткуда. Отметка
|
||||||
|
ставится в `service.Login` сразу после успешной проверки пароля; отказ записи
|
||||||
|
вход не отменяет, но попадает в журнал.
|
||||||
|
|
||||||
### Исправлено — правило доступа
|
### Исправлено — правило доступа
|
||||||
|
|
||||||
- **Исчерпанная квота не отключала пира никогда.** Правило доступа
|
- **Исчерпанная квота не отключала пира никогда.** Правило доступа
|
||||||
|
|||||||
@@ -634,8 +634,8 @@ hy2xs-orchestrator status \
|
|||||||
| `HY2XS_UI_BIND_HOST` | IPv4 bind HY2XS admin | `127.0.0.1` |
|
| `HY2XS_UI_BIND_HOST` | IPv4 bind HY2XS admin | `127.0.0.1` |
|
||||||
| `HY2XS_UI_PUBLIC_ACCESS` | Флаг публичного UI‑доступа. В baseline оставляйте `false` | `false` |
|
| `HY2XS_UI_PUBLIC_ACCESS` | Флаг публичного UI‑доступа. В baseline оставляйте `false` | `false` |
|
||||||
| `HY2XS_UI_PORT` | Порт HY2XS admin | `8080` |
|
| `HY2XS_UI_PORT` | Порт HY2XS admin | `8080` |
|
||||||
| `HY2XS_ADMIN_USER` | Bootstrap‑логин администратора | `hy2xsadmin` |
|
| `HY2XS_ADMIN_USER` | Bootstrap‑логин администратора: 6-32 символа из набора `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=`. Значение вне контракта роняет установку — панель его не приняла бы на форме входа | `hy2xsadmin` |
|
||||||
| `HY2XS_ADMIN_INITIAL_PASSWORD` | Bootstrap‑пароль администратора; `__GENERATE__` генерируется при install | `__GENERATE__` |
|
| `HY2XS_ADMIN_INITIAL_PASSWORD` | Bootstrap‑пароль администратора: 6-64 символа, набор символов не ограничен; `__GENERATE__` генерируется при install | `__GENERATE__` |
|
||||||
| `HY2XS_ADMIN_CON_PASS` | Bootstrap‑секрет peer/auth слоя; `__GENERATE__` генерируется при install | `__GENERATE__` |
|
| `HY2XS_ADMIN_CON_PASS` | Bootstrap‑секрет peer/auth слоя; `__GENERATE__` генерируется при install | `__GENERATE__` |
|
||||||
| `HY2XS_FORCE_PASSWORD_CHANGE` | Принудительная смена пароля. UX‑flow пока не включён в production baseline | `false` |
|
| `HY2XS_FORCE_PASSWORD_CHANGE` | Принудительная смена пароля. UX‑flow пока не включён в production baseline | `false` |
|
||||||
| `HY2XS_ALLOW_SELF_SIGNED_DEV` | Разрешает `self_signed_dev` TLS‑режим | `false` |
|
| `HY2XS_ALLOW_SELF_SIGNED_DEV` | Разрешает `self_signed_dev` TLS‑режим | `false` |
|
||||||
|
|||||||
@@ -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
|
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) {
|
func PagePeer(c *gin.Context) {
|
||||||
peerPageDto, err := validateField(c, dto.PeerPageDto{})
|
peerPageDto, err := validateField(c, dto.PeerPageDto{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"reflect"
|
"reflect"
|
||||||
"regexp"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -60,21 +59,22 @@ func validatePeerName(f validator.FieldLevel) bool {
|
|||||||
return service.IsValidPeerName(f.Field().String())
|
return service.IsValidPeerName(f.Field().String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// credentialStrPattern — набор символов логина и пароля администратора.
|
// validateCredentialStr — единственное правило имени администратора.
|
||||||
//
|
//
|
||||||
// Класс записан ЯВНО и повторяет прежнее ФАКТИЧЕСКОЕ множество, включая
|
// Набор символов и длина берутся из service по той же причине, что и у имени
|
||||||
// последствия неэкранированного дефиса в исходной записи `_+-=`. Это сделано
|
// пира: собственная копия правила в слое контроллеров уже расходилась с
|
||||||
// намеренно: имя администратора приходит из HY2XS_ADMIN_USER в hy2xs.env,
|
// остальным продуктом. Здесь она вдобавок была единственным местом, знавшим
|
||||||
// оркестратор набор символов не ограничивает, и сужение правила означало бы,
|
// набор, — оркестратор писал в hy2xs.env любое имя, а панель проверяла своё
|
||||||
// что установка с логином вроде `admin.ops` перестаёт пускать оператора в
|
// третье правило.
|
||||||
// панель. Сужать этот набор можно только вместе с проверкой имени на стороне
|
//
|
||||||
// оркестратора, и это отдельная работа, а не побочный эффект правки формы
|
// Пустое значение НЕ впускается. Раньше здесь стояло `field == "" || …`, и это
|
||||||
// пира.
|
// была ловушка: у поля-указателя `required` считает непустым сам факт
|
||||||
var credentialStrPattern = regexp.MustCompile(`^[a-zA-Z0-9!@#$%^&*()_+,\-./:;<=]{6,32}$`)
|
// ненулевого указателя, поэтому `{"username":""}` доходило сюда и объявлялось
|
||||||
|
// корректным. Пустой логин отсекается нормализацией DTO, которая превращает
|
||||||
|
// его в «не задано», и получает отказ `required` — то есть ту причину, которая
|
||||||
|
// у него на самом деле.
|
||||||
func validateCredentialStr(f validator.FieldLevel) bool {
|
func validateCredentialStr(f validator.FieldLevel) bool {
|
||||||
field := f.Field().String()
|
return service.IsValidAdminUsername(f.Field().String())
|
||||||
return field == "" || credentialStrPattern.MatchString(field)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateField разбирает запрос, приводит его к каноничному виду и проверяет
|
// validateField разбирает запрос, приводит его к каноничному виду и проверяет
|
||||||
@@ -202,8 +202,20 @@ func describeFieldError(fieldErr validator.FieldError) vo.FieldError {
|
|||||||
service.PeerNameMinLength, service.PeerNameMaxLength, service.PeerNameCharset,
|
service.PeerNameMinLength, service.PeerNameMaxLength, service.PeerNameCharset,
|
||||||
)
|
)
|
||||||
case "credentialStr":
|
case "credentialStr":
|
||||||
|
// Причина называет набор и границы ровно так же, как это делает
|
||||||
|
// `peerName`. Прежнее «содержит недопустимые символы» не отвечало на
|
||||||
|
// вопрос оператора: набор символов логина ему негде посмотреть, а
|
||||||
|
// отказ по длине этой фразой описывался как отказ по символам.
|
||||||
described.Code = constant.ErrCodeCredentialStr
|
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:
|
default:
|
||||||
described.Code = constant.ErrCodeRuleUnknown
|
described.Code = constant.ErrCodeRuleUnknown
|
||||||
described.Params = map[string]string{"rule": fieldErr.Tag()}
|
described.Params = map[string]string{"rule": fieldErr.Tag()}
|
||||||
|
|||||||
@@ -7,41 +7,96 @@ import (
|
|||||||
"hy2xs-admin/service"
|
"hy2xs-admin/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Набор символов логина и пароля закреплён ФАКТИЧЕСКИМ множеством.
|
// Набор символов логина администратора закреплён ФАКТИЧЕСКИМ множеством.
|
||||||
//
|
//
|
||||||
// Прежняя запись класса `[a-zA-Z0-9!@#$%^&*()_+-=]` содержала неэкранированный
|
// Прежняя запись класса `[a-zA-Z0-9!@#$%^&*()_+-=]` содержала неэкранированный
|
||||||
// дефис, из-за чего `+-=` образовывал диапазон и впускал `, - . / 0-9 : ; < =`.
|
// дефис, из-за чего `+-=` образовывал диапазон и впускал `, - . / 0-9 : ; < =`.
|
||||||
// Новая запись перечисляет эти символы явно и НЕ сужает множество: имя
|
// Действующая запись перечисляет эти символы явно и НЕ сужает множество: имя
|
||||||
// администратора приходит из HY2XS_ADMIN_USER в hy2xs.env, оркестратор его
|
// администратора приходит из HY2XS_ADMIN_USER в hy2xs.env, и сужение правила
|
||||||
// набор символов не ограничивает, и сужение правила означало бы, что установка
|
// означало бы, что установка с логином вроде `admin.ops` перестаёт пускать
|
||||||
// с логином вроде `admin.ops` перестаёт пускать оператора в панель.
|
// оператора в панель.
|
||||||
//
|
//
|
||||||
// Тест существует, чтобы это решение было явным: попытка «навести порядок» в
|
// Тест существует, чтобы это решение было явным: попытка «навести порядок» в
|
||||||
// классе символов уронит его, а не вход администратора на живом сервере.
|
// классе символов уронит его, а не вход администратора на живом сервере.
|
||||||
func TestCredentialCharsetIsUnchanged(t *testing.T) {
|
//
|
||||||
|
// Спрашивается service.IsValidAdminUsername, а не регексп рядом: правило
|
||||||
|
// объявлено там один раз, и проверка обязана спрашивать ровно то, что работает
|
||||||
|
// в бою. Прежняя версия обращалась к копии внутри слоя контроллеров — то есть
|
||||||
|
// не увидела бы расхождения между копией и правилом.
|
||||||
|
func TestAdminUsernameCharsetIsUnchanged(t *testing.T) {
|
||||||
const historical = "abcXYZ019" + "!@#$%^&*()_" + "+,-./:;<="
|
const historical = "abcXYZ019" + "!@#$%^&*()_" + "+,-./:;<="
|
||||||
|
|
||||||
for _, symbol := range strings.Split(historical, "") {
|
for _, symbol := range strings.Split(historical, "") {
|
||||||
candidate := "admin" + symbol
|
candidate := "admin" + symbol
|
||||||
if !credentialStrPattern.MatchString(candidate) {
|
if !service.IsValidAdminUsername(candidate) {
|
||||||
t.Errorf("символ %q больше не принимается логином: сужение набора ломает вход существующей установки", symbol)
|
t.Errorf("символ %q больше не принимается логином: сужение набора ломает вход существующей установки", symbol)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, rejected := range []string{
|
for _, rejected := range []string{
|
||||||
"admi", // короче шести символов
|
"", // не задано
|
||||||
strings.Repeat("a", 33), // длиннее тридцати двух
|
"admi", // короче шести символов
|
||||||
"admin пробел", // пробел
|
strings.Repeat("a", 33), // длиннее тридцати двух
|
||||||
"админ1", // кириллица
|
"admin пробел", // пробел
|
||||||
"admin\n1", // перевод строки
|
"админ1", // кириллица
|
||||||
"admin'1", // апостроф вне набора
|
"admin\n1", // перевод строки
|
||||||
|
"admin'1", // апостроф вне набора
|
||||||
} {
|
} {
|
||||||
if credentialStrPattern.MatchString(rejected) {
|
if service.IsValidAdminUsername(rejected) {
|
||||||
t.Errorf("значение %q принято логином, ожидался отказ", 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) {
|
func TestPeerNameRuleIsSharedWithImport(t *testing.T) {
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* Контракт учётных данных администратора на стороне панели.
|
||||||
|
*
|
||||||
|
* Зачем этот модуль существует. Правила логина и пароля жили прямо в двух
|
||||||
|
* формах и разошлись и с сервером, и друг с другом:
|
||||||
|
*
|
||||||
|
* форма входа логин 6-32 + набор символов, пароль 6-32 + набор
|
||||||
|
* форма смены пароля пароль 6-64 + набор
|
||||||
|
* сервер логин 6-32 + набор символов, пароль 6-64 без набора
|
||||||
|
*
|
||||||
|
* Следствий было два, и оба закрывали панель. Пароль, назначенный штатной
|
||||||
|
* формой смены, мог оказаться длиннее 32 символов — и форма входа отказывалась
|
||||||
|
* его отправлять: оператор терял доступ после операции, которую панель ему же и
|
||||||
|
* предложила. А набор символов на пароле отвергал значение, которое сервер
|
||||||
|
* принял бы, — панель была строже сервера там, где она не имеет на это права.
|
||||||
|
*
|
||||||
|
* Правило теперь одно на обе формы, и оно сверяется с Go-контрактом
|
||||||
|
* (apps/service/admin_credentials.go) тестом tools/test/frontend-contract.test.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const ADMIN_USERNAME_MIN_LENGTH = 6;
|
||||||
|
export const ADMIN_USERNAME_MAX_LENGTH = 32;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Набор символов логина в записи регекспа.
|
||||||
|
*
|
||||||
|
* Дефис ЭКРАНИРОВАН намеренно. В прежней записи `[a-zA-Z0-9!@#$%^&*()_+-=]` он
|
||||||
|
* экранирован не был, поэтому `+-=` образовывал ДИАПАЗОН и молча впускал
|
||||||
|
* `, - . / 0-9 : ; < =`. Действующий набор совпадает с этим фактическим
|
||||||
|
* множеством — сужать его нельзя, оно уже работает на установленных
|
||||||
|
* серверах, — но записан явно: пока он выглядел опечаткой, любая попытка
|
||||||
|
* «навести порядок» развела бы панель и сервер обратно.
|
||||||
|
*/
|
||||||
|
const ADMIN_USERNAME_CHARACTER_CLASS = "a-zA-Z0-9!@#$%^&*()_+,\\-./:;<=";
|
||||||
|
|
||||||
|
export const ADMIN_USERNAME_PATTERN = new RegExp(
|
||||||
|
`^[${ADMIN_USERNAME_CHARACTER_CLASS}]{${ADMIN_USERNAME_MIN_LENGTH},${ADMIN_USERNAME_MAX_LENGTH}}$`
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Тот же набор в том виде, в каком его показывают оператору. */
|
||||||
|
export const ADMIN_USERNAME_CHARSET = "a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Границы пароля. Набора символов у пароля НЕТ: сервер его не ограничивает ни
|
||||||
|
* при установке, ни при смене, и панель не имеет права отвергать значение,
|
||||||
|
* которое сервер принял бы.
|
||||||
|
*/
|
||||||
|
export const ADMIN_PASSWORD_MIN_LENGTH = 6;
|
||||||
|
export const ADMIN_PASSWORD_MAX_LENGTH = 64;
|
||||||
@@ -21,8 +21,12 @@ export default {
|
|||||||
password: "Password",
|
password: "Password",
|
||||||
login: "Login",
|
login: "Login",
|
||||||
capsLockOn: "Caps lock is On",
|
capsLockOn: "Caps lock is On",
|
||||||
usernameFormatIncorrect: "Username format is incorrect",
|
},
|
||||||
passwordFormatIncorrect: "Password format is incorrect",
|
// Требования к учётным данным администратора: общие для формы входа и формы
|
||||||
|
// смены пароля. См. комментарий в ru.ts.
|
||||||
|
credentials: {
|
||||||
|
usernameFormat: "Username: {min} to {max} characters from {charset}",
|
||||||
|
passwordLength: "Password: {min} to {max} characters",
|
||||||
},
|
},
|
||||||
dashboard: {
|
dashboard: {
|
||||||
stale: "Dashboard data is stale. Retrying automatically...",
|
stale: "Dashboard data is stale. Retrying automatically...",
|
||||||
@@ -161,7 +165,7 @@ export default {
|
|||||||
gt: "“{field}”: must be greater than {gt}",
|
gt: "“{field}”: must be greater than {gt}",
|
||||||
peer_name:
|
peer_name:
|
||||||
"“{field}”: {min} to {max} characters from {charset}. Spaces, non-latin letters and / : ; . are not allowed",
|
"“{field}”: {min} to {max} characters from {charset}. Spaces, non-latin letters and / : ; . are not allowed",
|
||||||
credential_format: "“{field}”: contains characters that are not allowed",
|
credential_format: "“{field}”: {min} to {max} characters from {charset}",
|
||||||
rule_violated: "“{field}”: value is not acceptable",
|
rule_violated: "“{field}”: value is not acceptable",
|
||||||
validation_failed: "Validation failed",
|
validation_failed: "Validation failed",
|
||||||
body_invalid:
|
body_invalid:
|
||||||
|
|||||||
@@ -19,8 +19,19 @@ export default {
|
|||||||
password: "Пароль",
|
password: "Пароль",
|
||||||
login: "Войти",
|
login: "Войти",
|
||||||
capsLockOn: "Caps Lock включён",
|
capsLockOn: "Caps Lock включён",
|
||||||
usernameFormatIncorrect: "Неверный формат логина",
|
},
|
||||||
passwordFormatIncorrect: "Неверный формат пароля",
|
// Требования к учётным данным администратора. Фразы общие для формы входа и
|
||||||
|
// формы смены пароля: требование одно, и второй его формулировки быть не
|
||||||
|
// должно — расхождение здесь означало бы, что оператору обещают разное про
|
||||||
|
// одно и то же поле.
|
||||||
|
//
|
||||||
|
// Обе фразы НАЗЫВАЮТ требование, а не сообщают о его нарушении. Прежние
|
||||||
|
// «Неверный формат логина» и «Некорректное значение» не давали оператору ни
|
||||||
|
// одного способа узнать, что именно от него хотят: набор символов логина
|
||||||
|
// приходит из hy2xs.env, и посмотреть его в панели негде.
|
||||||
|
credentials: {
|
||||||
|
usernameFormat: "Логин: от {min} до {max} символов из набора {charset}",
|
||||||
|
passwordLength: "Пароль: от {min} до {max} символов",
|
||||||
},
|
},
|
||||||
dashboard: {
|
dashboard: {
|
||||||
stale:
|
stale:
|
||||||
@@ -167,7 +178,11 @@ export default {
|
|||||||
gt: "«{field}»: значение должно быть больше {gt}",
|
gt: "«{field}»: значение должно быть больше {gt}",
|
||||||
peer_name:
|
peer_name:
|
||||||
"«{field}»: от {min} до {max} символов из набора {charset}. Пробелы, кириллица и знаки / : ; . недопустимы",
|
"«{field}»: от {min} до {max} символов из набора {charset}. Пробелы, кириллица и знаки / : ; . недопустимы",
|
||||||
credential_format: "«{field}»: недопустимые символы",
|
// Сервер присылает границы и набор в params — фраза называет требование
|
||||||
|
// целиком. Прежнее «недопустимые символы» вдобавок описывало этими же
|
||||||
|
// словами отказ по ДЛИНЕ: правило одно, и оно проверяет и то, и другое.
|
||||||
|
credential_format:
|
||||||
|
"«{field}»: от {min} до {max} символов из набора {charset}",
|
||||||
rule_violated: "«{field}»: значение не подходит",
|
rule_violated: "«{field}»: значение не подходит",
|
||||||
validation_failed: "Проверка данных не пройдена",
|
validation_failed: "Проверка данных не пройдена",
|
||||||
body_invalid: "Запрос не разобран: проверьте формат и типы полей",
|
body_invalid: "Запрос не разобран: проверьте формат и типы полей",
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ import { useI18n } from "vue-i18n";
|
|||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { adminChangePasswordApi } from "@/api/admin";
|
import { adminChangePasswordApi } from "@/api/admin";
|
||||||
import { useAdminStore } from "@/store/modules/admin";
|
import { useAdminStore } from "@/store/modules/admin";
|
||||||
|
import {
|
||||||
|
ADMIN_PASSWORD_MAX_LENGTH,
|
||||||
|
ADMIN_PASSWORD_MIN_LENGTH,
|
||||||
|
} from "@/constants/credentials";
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -46,7 +50,29 @@ const form = reactive({
|
|||||||
newPassword: "",
|
newPassword: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
const passwordPattern = /^[a-zA-Z0-9!@#$%^&*()_+-=]{6,64}$/;
|
// Проверяется ТОЛЬКО длина, и она берётся из общего контракта.
|
||||||
|
//
|
||||||
|
// Здесь стояло правило набора символов `[a-zA-Z0-9!@#$%^&*()_+-=]`, которого
|
||||||
|
// сервер не предъявляет ни при смене пароля, ни при установке. То есть панель
|
||||||
|
// отказывала оператору в пароле, который сервер принял бы, и сообщала об этом
|
||||||
|
// фразой «Некорректное значение», не называя ни одного требования.
|
||||||
|
//
|
||||||
|
// Границы совпадают с формой входа не случайно: пока они расходились, длинный
|
||||||
|
// пароль, назначенный здесь, невозможно было ввести там.
|
||||||
|
//
|
||||||
|
// Комментарий записан строчными `//`, а не блоком: скан релизных гейтов
|
||||||
|
// отбрасывает только их, и объяснение, называющее убранную конструкцию по
|
||||||
|
// имени, иначе роняет проверку «этой конструкции здесь больше нет».
|
||||||
|
const passwordRule = {
|
||||||
|
min: ADMIN_PASSWORD_MIN_LENGTH,
|
||||||
|
max: ADMIN_PASSWORD_MAX_LENGTH,
|
||||||
|
message: t("credentials.passwordLength", {
|
||||||
|
min: ADMIN_PASSWORD_MIN_LENGTH,
|
||||||
|
max: ADMIN_PASSWORD_MAX_LENGTH,
|
||||||
|
}),
|
||||||
|
trigger: ["change", "blur"] as string[],
|
||||||
|
};
|
||||||
|
|
||||||
const rules: FormRules = {
|
const rules: FormRules = {
|
||||||
oldPassword: [
|
oldPassword: [
|
||||||
{
|
{
|
||||||
@@ -54,11 +80,7 @@ const rules: FormRules = {
|
|||||||
message: t("common.required"),
|
message: t("common.required"),
|
||||||
trigger: ["change", "blur"],
|
trigger: ["change", "blur"],
|
||||||
},
|
},
|
||||||
{
|
{ ...passwordRule },
|
||||||
pattern: passwordPattern,
|
|
||||||
message: t("common.invalid"),
|
|
||||||
trigger: ["change", "blur"],
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
newPassword: [
|
newPassword: [
|
||||||
{
|
{
|
||||||
@@ -66,11 +88,7 @@ const rules: FormRules = {
|
|||||||
message: t("common.required"),
|
message: t("common.required"),
|
||||||
trigger: ["change", "blur"],
|
trigger: ["change", "blur"],
|
||||||
},
|
},
|
||||||
{
|
{ ...passwordRule },
|
||||||
pattern: passwordPattern,
|
|
||||||
message: t("common.invalid"),
|
|
||||||
trigger: ["change", "blur"],
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,14 @@ import { useAdminStore } from "@/store/modules/admin";
|
|||||||
// Зависимость API
|
// Зависимость API
|
||||||
import { LocationQuery, LocationQueryValue, useRoute } from "vue-router";
|
import { LocationQuery, LocationQueryValue, useRoute } from "vue-router";
|
||||||
import { AdminLoginDto } from "@/api/admin/types";
|
import { AdminLoginDto } from "@/api/admin/types";
|
||||||
|
import {
|
||||||
|
ADMIN_PASSWORD_MAX_LENGTH,
|
||||||
|
ADMIN_PASSWORD_MIN_LENGTH,
|
||||||
|
ADMIN_USERNAME_CHARSET,
|
||||||
|
ADMIN_USERNAME_MAX_LENGTH,
|
||||||
|
ADMIN_USERNAME_MIN_LENGTH,
|
||||||
|
ADMIN_USERNAME_PATTERN,
|
||||||
|
} from "@/constants/credentials";
|
||||||
|
|
||||||
const adminStore = useAdminStore();
|
const adminStore = useAdminStore();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -114,6 +122,14 @@ const loginForm = ref<AdminLoginDto>({
|
|||||||
pass: "",
|
pass: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Правила формы входа берутся из общего контракта, а не пишутся здесь.
|
||||||
|
*
|
||||||
|
* У пароля проверяется ТОЛЬКО длина. Прежнее правило требовало ещё и набор
|
||||||
|
* символов, из-за чего форма входа отказывалась отправлять пароль, который
|
||||||
|
* сервер принимает: набор пароля сервер не ограничивает нигде. Проверка,
|
||||||
|
* которая умеет только запереть оператора и ничего не защищает, — не проверка.
|
||||||
|
*/
|
||||||
const loginRules = {
|
const loginRules = {
|
||||||
username: [
|
username: [
|
||||||
{
|
{
|
||||||
@@ -122,8 +138,12 @@ const loginRules = {
|
|||||||
trigger: ["change", "blur"],
|
trigger: ["change", "blur"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
pattern: /^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$/,
|
pattern: ADMIN_USERNAME_PATTERN,
|
||||||
message: t("login.usernameFormatIncorrect"),
|
message: t("credentials.usernameFormat", {
|
||||||
|
min: ADMIN_USERNAME_MIN_LENGTH,
|
||||||
|
max: ADMIN_USERNAME_MAX_LENGTH,
|
||||||
|
charset: ADMIN_USERNAME_CHARSET,
|
||||||
|
}),
|
||||||
trigger: ["change", "blur"],
|
trigger: ["change", "blur"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -134,8 +154,12 @@ const loginRules = {
|
|||||||
trigger: ["change", "blur"],
|
trigger: ["change", "blur"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
pattern: /^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$/,
|
min: ADMIN_PASSWORD_MIN_LENGTH,
|
||||||
message: t("login.passwordFormatIncorrect"),
|
max: ADMIN_PASSWORD_MAX_LENGTH,
|
||||||
|
message: t("credentials.passwordLength", {
|
||||||
|
min: ADMIN_PASSWORD_MIN_LENGTH,
|
||||||
|
max: ADMIN_PASSWORD_MAX_LENGTH,
|
||||||
|
}),
|
||||||
trigger: ["change", "blur"],
|
trigger: ["change", "blur"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -205,10 +229,61 @@ const handleLogin = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Видимое поле формы входа — это `el-form-item`, а не `el-input`.
|
||||||
|
//
|
||||||
|
// Рамка и фон нарисованы здесь, потому что внутрь одного поля кладутся три
|
||||||
|
// вещи: иконка, ввод и переключатель видимости пароля. `el-input` занимает лишь
|
||||||
|
// среднюю из них.
|
||||||
|
//
|
||||||
|
// Отсюда и дефект индикации ошибки, который был виден на форме. Element Plus
|
||||||
|
// рисует состояние отказа на `el-input__wrapper` правилом
|
||||||
|
//
|
||||||
|
// .el-form-item.is-error .el-form-item__content .el-input__wrapper
|
||||||
|
//
|
||||||
|
// то есть селектором из ЧЕТЫРЁХ классов, а здешнее гашение тени записывалось
|
||||||
|
// селектором из трёх — и проигрывало по специфичности. В результате красная
|
||||||
|
// рамка ложилась вокруг одного лишь поля ввода: у логина она начиналась после
|
||||||
|
// иконки пользователя, у пароля обрывалась перед «глазом», и ни одна её сторона
|
||||||
|
// не совпадала с видимой границей поля.
|
||||||
|
//
|
||||||
|
// Чинится это не увеличением специфичности ради победы, а переносом индикации
|
||||||
|
// на тот элемент, который оператор и видит полем.
|
||||||
.el-form-item {
|
.el-form-item {
|
||||||
background: var(--subMenuBg);
|
background: var(--subMenuBg);
|
||||||
border: 1px solid rgb(255 255 255 / 12%);
|
border: 1px solid rgb(255 255 255 / 12%);
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
|
|
||||||
|
// Просвет под полем принадлежит сообщению об отказе: `el-form-item__error`
|
||||||
|
// позиционируется абсолютно от `top: 100%`, то есть живёт ВНЕ рамки. При
|
||||||
|
// стандартных 18px оно вплотную прижималось к границе снизу и к следующему
|
||||||
|
// полю сверху.
|
||||||
|
margin-bottom: 26px;
|
||||||
|
|
||||||
|
&.is-error {
|
||||||
|
border-color: var(--el-color-danger);
|
||||||
|
|
||||||
|
// Штатная индикация Element Plus гасится ЗДЕСЬ, а не в блоке `.el-input`:
|
||||||
|
// селектор повторяет её собственный и добавляет атрибут scoped-стиля,
|
||||||
|
// поэтому выигрывает по специфичности. Прежнее гашение стояло на два
|
||||||
|
// класса ниже и проигрывало — из-за чего красный прямоугольник вокруг
|
||||||
|
// одного лишь поля ввода и появлялся. `!important` здесь не нужен: правило
|
||||||
|
// не сильнее чужого, а конкретнее.
|
||||||
|
:deep(.el-form-item__content .el-input__wrapper) {
|
||||||
|
&,
|
||||||
|
&:hover,
|
||||||
|
&:focus,
|
||||||
|
&.is-focus {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сообщение выравнивается по тексту поля, а не по краю рамки: иначе оно
|
||||||
|
// висит на сдвиг левее всего, что находится над ним.
|
||||||
|
:deep(.el-form-item__error) {
|
||||||
|
padding-top: 6px;
|
||||||
|
padding-left: 12px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-input {
|
.el-input {
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
package dto
|
package dto
|
||||||
|
|
||||||
|
// Границы пароля здесь и в LoginDto обязаны совпадать.
|
||||||
|
//
|
||||||
|
// Пока они расходились, панель запирала оператора снаружи после операции,
|
||||||
|
// которую сама же и предлагала: форма смены пароля принимала 64 символа, а
|
||||||
|
// форма входа — 32, и назначенный длинный пароль становился паролем, с которым
|
||||||
|
// невозможно войти. Числа в тегах сверяются с service.AdminPassword*Length в
|
||||||
|
// TestAdminPasswordBoundsAreOneContract — в теге структуры константу не
|
||||||
|
// подставить.
|
||||||
|
//
|
||||||
|
// Набора символов здесь нет и не было: пароль принадлежит оператору целиком.
|
||||||
|
|
||||||
type AdminChangePasswordDto struct {
|
type AdminChangePasswordDto struct {
|
||||||
OldPassword *string `json:"oldPassword" form:"oldPassword" validate:"required,min=6,max=64"`
|
OldPassword *string `json:"oldPassword" form:"oldPassword" validate:"required,min=6,max=64"`
|
||||||
NewPassword *string `json:"newPassword" form:"newPassword" validate:"required,min=6,max=64"`
|
NewPassword *string `json:"newPassword" form:"newPassword" validate:"required,min=6,max=64"`
|
||||||
|
|||||||
+47
-1
@@ -1,6 +1,52 @@
|
|||||||
package dto
|
package dto
|
||||||
|
|
||||||
|
// Контракт формы входа.
|
||||||
|
//
|
||||||
|
// Раньше на Username стояло `required,min=6,max=32,validateStr`, и правила
|
||||||
|
// `validateStr` не существовало: оно пережило переименование в `credentialStr`
|
||||||
|
// и осталось ссылкой на незарегистрированную функцию. go-playground/validator в
|
||||||
|
// таком случае ПАНИКУЕТ, поэтому каждый POST /api/auth/login заканчивался
|
||||||
|
// паникой, которую gin.Recovery превращал в HTTP 500 — панель после чистой
|
||||||
|
// установки была недоступна целиком. Ни один тест этого не видел: проверялся
|
||||||
|
// сам регексп, а не LoginDto через production-валидатор, и цепочка правил на
|
||||||
|
// нулевом DTO обрывается на `required` раньше, чем доходит до испорченного
|
||||||
|
// тега.
|
||||||
|
//
|
||||||
|
// Границы длины логина живут ВНУТРИ правила `credentialStr`, а не рядом с ним.
|
||||||
|
// Два правила на одном поле уже приводили к отказу, который невозможно
|
||||||
|
// объяснить: имя пира с `min=1,max=32,validateStr` принималось одним правилом
|
||||||
|
// и отвергалось другим, а оператор видел «invalid». Здесь та же конструкция
|
||||||
|
// была бы той же ошибкой.
|
||||||
|
//
|
||||||
|
// У пароля набора символов НЕТ, и это осознанно: сервер нигде его не
|
||||||
|
// ограничивает — ни при установке, ни при смене пароля. Ограничение на входе
|
||||||
|
// не защищало бы ничего (значение всё равно сравнивается с хешем) и умело
|
||||||
|
// только запирать оператора с паролем, который сервер принял бы.
|
||||||
|
//
|
||||||
|
// Числа в тегах обязаны совпадать с service.AdminPassword*Length; за этим
|
||||||
|
// следит TestLoginPasswordBoundsMatchContract — в теге структуры константу не
|
||||||
|
// подставить.
|
||||||
type LoginDto struct {
|
type LoginDto struct {
|
||||||
Username *string `json:"username" form:"username" validate:"required,min=6,max=32,validateStr"`
|
Username *string `json:"username" form:"username" validate:"required,credentialStr"`
|
||||||
Pass *string `json:"pass" form:"pass" validate:"required,min=6,max=64"`
|
Pass *string `json:"pass" form:"pass" validate:"required,min=6,max=64"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalize: незаполненное поле обязано выглядеть незаполненным.
|
||||||
|
//
|
||||||
|
// У поля-указателя `required` считает значение заданным по ненулевому
|
||||||
|
// указателю, поэтому `{"username":"","pass":""}` проходило `required` целиком и
|
||||||
|
// получало отказ по набору символов и по длине — то есть оператор, отправивший
|
||||||
|
// пустую форму, читал про недопустимые символы вместо «поле обязательно».
|
||||||
|
//
|
||||||
|
// Логин при этом ещё и триммится: скопированное из bootstrap-admin.secret
|
||||||
|
// значение регулярно приезжает с пробелом или переводом строки на конце, и
|
||||||
|
// раньше это давало «неверный логин или пароль» — отказ, который невозможно
|
||||||
|
// связать с причиной.
|
||||||
|
//
|
||||||
|
// Пароль НЕ триммится. Набор его символов не ограничен, пробел по краям —
|
||||||
|
// законная часть значения, и молчаливое его удаление означало бы, что панель
|
||||||
|
// отправляет не то, что ввёл оператор.
|
||||||
|
func (d *LoginDto) Normalize() {
|
||||||
|
blankToNil(&d.Username)
|
||||||
|
emptyToNil(&d.Pass)
|
||||||
|
}
|
||||||
|
|||||||
@@ -66,6 +66,24 @@ func trimValue(field *string) {
|
|||||||
*field = strings.TrimSpace(*field)
|
*field = strings.TrimSpace(*field)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// emptyToNil: пустая строка означает «не задано», и ничего больше.
|
||||||
|
//
|
||||||
|
// Отличается от blankToNil тем, что НЕ трогает пробелы. Применяется к полям,
|
||||||
|
// содержимое которых принадлежит оператору целиком, — к паролю: набор его
|
||||||
|
// символов не ограничен, пробел по краям является частью значения, и
|
||||||
|
// молчаливое его удаление означало бы, что на сервер уезжает не то, что было
|
||||||
|
// введено. Пустое поле при этом обязано получить отказ `required`, а не отказ
|
||||||
|
// по длине: у поля-указателя `required` считает значение заданным по самому
|
||||||
|
// факту ненулевого указателя.
|
||||||
|
func emptyToNil(field **string) {
|
||||||
|
if *field == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if **field == "" {
|
||||||
|
*field = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// zeroToNil: ноль у необязательного числового поля означает «не задано».
|
// zeroToNil: ноль у необязательного числового поля означает «не задано».
|
||||||
//
|
//
|
||||||
// Применяется ТОЛЬКО там, где ноль не является осмысленным значением:
|
// Применяется ТОЛЬКО там, где ноль не является осмысленным значением:
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Контракт учётных данных администратора — ОДИН на весь продукт.
|
||||||
|
//
|
||||||
|
// Зачем он вынесен сюда. Про логин и пароль администратора одновременно знали
|
||||||
|
// четыре места: регексп в слое контроллеров, границы длины в тегах LoginDto,
|
||||||
|
// правило формы входа в панели и правило формы смены пароля в ней же. Ни одно
|
||||||
|
// из них не ссылалось на остальные, и все четыре успели разойтись:
|
||||||
|
//
|
||||||
|
// - тег `validateStr` на Username пережил переименование правила и остался
|
||||||
|
// ссылкой на несуществующую функцию — валидатор паниковал на КАЖДОМ входе,
|
||||||
|
// а gin.Recovery превращал панику в HTTP 500;
|
||||||
|
// - форма входа ограничивала пароль 32 символами, форма смены пароля — 64,
|
||||||
|
// сервер — тоже 64. То есть пароль, назначенный штатной формой смены,
|
||||||
|
// форма входа отказывалась отправлять: панель запирала оператора снаружи
|
||||||
|
// после операции, которую сама же и предложила;
|
||||||
|
// - оркестратор не проверял HY2XS_ADMIN_USER вовсе и по умолчанию писал в
|
||||||
|
// hy2xs.env `admin` — пять символов при минимуме в шесть. Установка
|
||||||
|
// завершалась успешно, а войти в панель было нельзя.
|
||||||
|
//
|
||||||
|
// Общее у всех трёх — контракт существовал только как совпадение литералов,
|
||||||
|
// которое ничто не проверяло. Теперь он объявлен здесь, слой контроллеров зовёт
|
||||||
|
// IsValidAdminUsername, а расхождение с панелью и оркестратором ловится
|
||||||
|
// тестами, читающими эти же константы.
|
||||||
|
const (
|
||||||
|
AdminUsernameMinLength = 6
|
||||||
|
AdminUsernameMaxLength = 32
|
||||||
|
|
||||||
|
// Границы пароля. Верхняя выбрана не круглым числом: bcrypt читает первые
|
||||||
|
// 72 БАЙТА и молча отбрасывает остаток, поэтому предел обязан быть заведомо
|
||||||
|
// ниже — иначе два разных пароля, совпадающие в начале, окажутся одним и
|
||||||
|
// тем же паролем.
|
||||||
|
AdminPasswordMinLength = 6
|
||||||
|
AdminPasswordMaxLength = 64
|
||||||
|
)
|
||||||
|
|
||||||
|
// adminUsernameCharacterClass — набор символов логина в записи регекспа.
|
||||||
|
//
|
||||||
|
// Класс записан ЯВНО и повторяет прежнее ФАКТИЧЕСКОЕ множество, включая
|
||||||
|
// последствия неэкранированного дефиса в исходной записи `_+-=`: там `+-=`
|
||||||
|
// образовывал ДИАПАЗОН и впускал `, - . / 0-9 : ; < =`. Сужать набор здесь
|
||||||
|
// нельзя в одиночку — имя администратора приходит из HY2XS_ADMIN_USER, и
|
||||||
|
// установка с логином вроде `admin.ops` перестала бы пускать оператора в
|
||||||
|
// панель. Набор и проверка на стороне оркестратора меняются только вместе.
|
||||||
|
const adminUsernameCharacterClass = `a-zA-Z0-9!@#$%^&*()_+,\-./:;<=`
|
||||||
|
|
||||||
|
// AdminUsernameCharset — тот же набор в том виде, в каком его показывают
|
||||||
|
// оператору. Объявлен рядом с регекспом, чтобы сообщение об отказе не заводило
|
||||||
|
// собственную копию того же множества.
|
||||||
|
const AdminUsernameCharset = `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=`
|
||||||
|
|
||||||
|
// adminUsernamePattern собирается из констант, а не пишется литералом: границы
|
||||||
|
// длины обязаны быть одними и теми же для правила и для сообщения о нём.
|
||||||
|
var adminUsernamePattern = regexp.MustCompile(
|
||||||
|
"^[" + adminUsernameCharacterClass + "]{" +
|
||||||
|
strconv.Itoa(AdminUsernameMinLength) + "," +
|
||||||
|
strconv.Itoa(AdminUsernameMaxLength) + "}$",
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsValidAdminUsername сообщает, пригодно ли имя администратора.
|
||||||
|
//
|
||||||
|
// Пробелы по краям снимаются здесь повторно: через форму входа они уже сняты
|
||||||
|
// нормализацией DTO, но эту же функцию зовёт проверка HY2XS_ADMIN_USER, куда
|
||||||
|
// значение приходит из файла окружения, а не из DTO.
|
||||||
|
func IsValidAdminUsername(username string) bool {
|
||||||
|
return adminUsernamePattern.MatchString(strings.TrimSpace(username))
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValidAdminPassword проверяет ТОЛЬКО границы длины.
|
||||||
|
//
|
||||||
|
// Набор символов пароля не ограничен, и это осознанно. Пароль назначает
|
||||||
|
// оператор — установкой через HY2XS_ADMIN_INITIAL_PASSWORD или формой смены, —
|
||||||
|
// и сервер нигде его набор не проверяет. Ограничение на форме входа не
|
||||||
|
// защищает ничего: значение всё равно сравнивается с хешем, — зато отвергает
|
||||||
|
// пароль, который сервер принял бы, то есть умеет только запереть оператора.
|
||||||
|
//
|
||||||
|
// Длина считается в РУНАХ: ровно так её считает go-playground/validator в
|
||||||
|
// правилах `min`/`max` на строке, и расхождение означало бы, что пароль с
|
||||||
|
// кириллицей проходит одну проверку и отказывается на другой.
|
||||||
|
func IsValidAdminPassword(password string) bool {
|
||||||
|
length := utf8.RuneCountInString(password)
|
||||||
|
return length >= AdminPasswordMinLength && length <= AdminPasswordMaxLength
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
"hy2xs-admin/dao"
|
"hy2xs-admin/dao"
|
||||||
"hy2xs-admin/model/bo"
|
"hy2xs-admin/model/bo"
|
||||||
"hy2xs-admin/model/constant"
|
"hy2xs-admin/model/constant"
|
||||||
@@ -58,6 +59,23 @@ func Login(username string, plainPassword string) (string, bool, error) {
|
|||||||
if tokenErr != nil {
|
if tokenErr != nil {
|
||||||
return "", false, tokenErr
|
return "", false, tokenErr
|
||||||
}
|
}
|
||||||
|
// Отметка о входе ставится здесь, а не в слое контроллеров.
|
||||||
|
//
|
||||||
|
// Колонка `last_login_at` объявлена и в схеме, и в entity, а писать её было
|
||||||
|
// некому: UpdateAdminLastLoginAt существовал и не вызывался ниоткуда, то
|
||||||
|
// есть панель показывала бы «последний вход» как 1970 год на живом сервере.
|
||||||
|
// Место выбрано так, чтобы записать её было невозможно забыть: успешная
|
||||||
|
// проверка пароля — единственная дверь, и она здесь одна.
|
||||||
|
//
|
||||||
|
// Отказ записи НЕ отменяет вход. Учётные данные уже подтверждены, и
|
||||||
|
// превращать неудачу бухгалтерии в отказ аутентификации значило бы закрыть
|
||||||
|
// панель из-за строки статистики. Молчать при этом тоже нельзя: неписаная
|
||||||
|
// отметка — расхождение между тем, что показывает панель, и тем, что
|
||||||
|
// произошло.
|
||||||
|
if err = UpdateAdminLastLoginAt(*admin.Id, time.Now().UnixMilli()); err != nil {
|
||||||
|
logrus.Errorf("не удалось записать время входа администратора %d: %v", *admin.Id, err)
|
||||||
|
}
|
||||||
|
|
||||||
// Признак «пароль в старом формате, заставить сменить» отсюда убран вместе
|
// Признак «пароль в старом формате, заставить сменить» отсюда убран вместе
|
||||||
// с поддержкой самого формата: единственный источник этого требования —
|
// с поддержкой самого формата: единственный источник этого требования —
|
||||||
// флаг force_password_change, который ставит установка.
|
// флаг force_password_change, который ставит установка.
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
# Runtime-прогон `1.0.0-rc2` на чистом Debian 13
|
||||||
|
|
||||||
|
```text
|
||||||
|
Findings base: 82e5ca40 — дерево, на котором собран проверявшийся RC2
|
||||||
|
Fixes verified in: рабочее дерево этого прохода
|
||||||
|
Хост: чистый Debian 13, установка с нуля из release-архива
|
||||||
|
```
|
||||||
|
|
||||||
|
Провенанс у этого файла другой, чем у соседних preflight-разборов: дефект
|
||||||
|
наблюдался **на хосте**, а не найден чтением дерева. Установка прошла целиком и
|
||||||
|
объявила успех, после чего панель оказалась недоступна.
|
||||||
|
|
||||||
|
## Статус прогона
|
||||||
|
|
||||||
|
```text
|
||||||
|
RC2 BUILD ACCEPTANCE: PASS
|
||||||
|
RC2 CLEAN INSTALL: PASS
|
||||||
|
RC2 SERVER RUNTIME: PASS
|
||||||
|
RC2 ADMIN LOGIN: FAIL
|
||||||
|
RC2 OVERALL RUNTIME: REJECTED
|
||||||
|
```
|
||||||
|
|
||||||
|
Hysteria работает, сервер не повреждён, паника восстановима. Приёмка RC2
|
||||||
|
останавливается здесь: основная admin-панель после чистой установки недоступна
|
||||||
|
целиком, и это P0 для release candidate.
|
||||||
|
|
||||||
|
## Сводка
|
||||||
|
|
||||||
|
| ID | Дефект | Приоритет | Статус |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| LOGIN-01 | `POST /api/auth/login` паниковал на теге незарегистрированного правила | P0 | закрыт |
|
||||||
|
| LOGIN-02 | Ни один тест не прогонял DTO через production-валидатор | P0 | закрыт |
|
||||||
|
| LOGIN-03 | Установка объявляла успех, не проверив, что в панель можно войти | P0 | закрыт |
|
||||||
|
| LOGIN-04 | Оркестратор не знал контракта учётных данных и по умолчанию писал невалидный логин | P0 | закрыт |
|
||||||
|
| LOGIN-05 | Форма входа была строже сервера и запирала оператора после смены пароля | P1 | закрыт |
|
||||||
|
| LOGIN-06 | Индикация ошибки на форме входа рисовалась вокруг не того элемента | P2 | закрыт |
|
||||||
|
| LOGIN-07 | Класс символов записан диапазоном по опечатке в двух формах панели | P2 | закрыт |
|
||||||
|
| LOGIN-08 | `last_login_at` объявлен в схеме, но не записывался никогда | P3 | закрыт |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LOGIN-01 — вход паниковал до проверки учётных данных
|
||||||
|
|
||||||
|
**Наблюдалось на хосте.** Каждый `POST /api/auth/login` отдавал HTTP 500. В
|
||||||
|
journal на каждый запрос:
|
||||||
|
|
||||||
|
```text
|
||||||
|
panic recovered:
|
||||||
|
Undefined validation function 'validateStr' on field 'Username'
|
||||||
|
|
||||||
|
controller/validator.go:115
|
||||||
|
controller/peer.go:50
|
||||||
|
```
|
||||||
|
|
||||||
|
В `apps/model/dto/auth.go` на поле стоял тег `validateStr`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
Username *string `json:"username" ... validate:"required,min=6,max=32,validateStr"`
|
||||||
|
```
|
||||||
|
|
||||||
|
Правило с таким именем не регистрируется: при переименовании в `credentialStr`
|
||||||
|
правка не доехала до одного файла. `go-playground/validator` на неизвестный тег
|
||||||
|
ПАНИКУЕТ при разборе структуры — то есть до всякой проверки логина и пароля, —
|
||||||
|
а `gin.Recovery` превращал панику в HTTP 500.
|
||||||
|
|
||||||
|
Побочно это подтверждается тем, что зарегистрированное правило `credentialStr`
|
||||||
|
не использовалось **нигде**: переименование оставило после себя мёртвую
|
||||||
|
регистрацию и живую ссылку на несуществующее имя.
|
||||||
|
|
||||||
|
**Закрыто.** Контракт учётных данных вынесен в
|
||||||
|
`apps/service/admin_credentials.go` — по образцу уже существующего
|
||||||
|
`IsValidPeerName`. Правило `credentialStr` зовёт его, длина живёт ВНУТРИ
|
||||||
|
правила (два правила длины на одном поле уже приводили к необъяснимому отказу
|
||||||
|
на имени пира), а обработчик входа переехал в `apps/controller/auth.go`: пока
|
||||||
|
он лежал в `peer.go`, стек указывал на управление пирами — подсистему, не
|
||||||
|
имеющую к отказу отношения.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LOGIN-02 — 311 Go-тестов не видели дефекта
|
||||||
|
|
||||||
|
**Наблюдалось по дереву.** `validator_test.go` проверял регексп `credentialStr`
|
||||||
|
НАПРЯМУЮ, в обход валидатора, а обработчика входа не касался ни один тест.
|
||||||
|
|
||||||
|
Важно, почему очевидная проверка не помогла бы. Прогон нулевого `LoginDto`
|
||||||
|
через `validate.Struct` дефекта НЕ ловит: цепочка правил поля обрывается на
|
||||||
|
первом несработавшем, поэтому на пустом `Username` проверка отказывает по
|
||||||
|
`required` и до испорченного тега не доходит.
|
||||||
|
|
||||||
|
**Закрыто** барьером, закрывающим КЛАСС, а не найденный экземпляр:
|
||||||
|
`TestEveryValidationTagIsRegistered` извлекает все теги `validate:"…"` из
|
||||||
|
`apps/model/**` и предъявляет каждый валидатору отдельно. Незарегистрированное
|
||||||
|
правило паникует так же, как паниковало в бою, — но на сборке. Барьер проверен
|
||||||
|
возвратом исходного тега: тест падает с именем правила и файлом.
|
||||||
|
|
||||||
|
Сверх него добавлены прогон `LoginDto` через production-валидатор, таблица
|
||||||
|
негативных случаев с ожидаемыми кодами причин и HTTP-регрессия обработчика — в
|
||||||
|
том числе за `gin.Recovery`, то есть ровно в той конфигурации, в которой дефект
|
||||||
|
наблюдался.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LOGIN-03 — установка не проверяла, что в панель можно войти
|
||||||
|
|
||||||
|
**Наблюдалось на хосте.** Установка завершилась `INSTALL EXIT CODE: 0` при
|
||||||
|
полностью недоступной панели.
|
||||||
|
|
||||||
|
Smoke отвечал на вопрос «работает ли панель» тремя фактами: юнит активен,
|
||||||
|
`127.0.0.1:8080` в `LISTEN`, `/healthz` отвечает `ok: true`. Все три были
|
||||||
|
истинны. Факт `LISTEN` не означает, что панель функциональна, — RC2 это
|
||||||
|
буквально доказал.
|
||||||
|
|
||||||
|
**Закрыто.** `orchestrator/src/steps/smoke.ts` выполняет настоящий
|
||||||
|
`POST /api/auth/login` bootstrap-учётными данными и требует `code: 20000` с
|
||||||
|
непустым `accessToken`; успех определяется КОНВЕРТОМ, а не кодом HTTP — админка
|
||||||
|
отвечает `200 OK` и на отказ тоже. Отдельная отрицательная проба выполняется в
|
||||||
|
любом режиме операции и не зависит от актуальности пароля: заведомо неверные
|
||||||
|
учётные данные обязаны получить конверт отказа, а не 500. Учётные данные не
|
||||||
|
попадают ни в текст ошибки, ни в журнал.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LOGIN-04 — оркестратор не знал контракта, который сам порождает
|
||||||
|
|
||||||
|
**Найдено при разборе смежного кода.** `orchestrator/src/config/env.ts` брал
|
||||||
|
логин как
|
||||||
|
|
||||||
|
```ts
|
||||||
|
adminUser: requireValue("HY2XS_ADMIN_USER", env.HY2XS_ADMIN_USER || "admin")
|
||||||
|
```
|
||||||
|
|
||||||
|
`admin` — пять символов при минимуме панели в шесть. При пустом
|
||||||
|
`HY2XS_ADMIN_USER` установка проходила целиком и создавала учётную запись, под
|
||||||
|
которой невозможно войти. Про одно и то же имя существовало три расходящихся
|
||||||
|
умолчания: `admin` здесь, `hy2xsadmin` в `apps/dao/sqlite.go` и `hy2xsadmin` в
|
||||||
|
`package/config/hy2xs.env`.
|
||||||
|
|
||||||
|
Ни логин, ни операторский `HY2XS_ADMIN_INITIAL_PASSWORD` не проверялись против
|
||||||
|
контракта панели вовсе.
|
||||||
|
|
||||||
|
**Закрыто.** Оба значения проверяются при разборе окружения — той стороной,
|
||||||
|
которая их ПОРОЖДАЕТ: отказ, пришедший установщику, чинится одной строкой в
|
||||||
|
`hy2xs.env`, а неработающий вход на готовом сервере — переустановкой. Умолчание
|
||||||
|
сведено к `hy2xsadmin` во всех трёх местах. Проверяется и сгенерированный
|
||||||
|
пароль: генератор — такой же источник значения.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LOGIN-05 — панель была строже сервера и запирала после смены пароля
|
||||||
|
|
||||||
|
**Найдено при разборе панели.** Границы пароля различались на трёх сторонах:
|
||||||
|
|
||||||
|
| Где | Логин | Пароль |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| форма входа | 6-32 + набор | 6-**32** + набор |
|
||||||
|
| форма смены пароля | — | 6-**64** + набор |
|
||||||
|
| сервер | 6-32 + набор | 6-**64**, набора нет |
|
||||||
|
|
||||||
|
Следствий два, и оба закрывают панель. Пароль длиннее 32 символов назначался
|
||||||
|
штатной формой смены и после этого не вводился на форме входа: оператор терял
|
||||||
|
доступ после операции, которую панель ему же и предложила. А набор символов на
|
||||||
|
пароле отвергал значение, которое сервер принял бы, — в том числе
|
||||||
|
`HY2XS_ADMIN_INITIAL_PASSWORD`, заданный оператором со знаком вне набора.
|
||||||
|
|
||||||
|
**Закрыто.** Правило объявлено один раз в
|
||||||
|
`apps/frontend/src/constants/credentials.ts` и используется обеими формами;
|
||||||
|
набор символов с пароля снят — сервер его не предъявляет нигде, а проверка,
|
||||||
|
умеющая только запереть оператора, не защищает ничего. Совпадение с Go-контрактом
|
||||||
|
удерживается тестом, читающим Go-исходник.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LOGIN-06 — красная рамка обводила не то, что видит оператор
|
||||||
|
|
||||||
|
**Наблюдалось на экране.** При отказе проверки красная рамка ложилась вокруг
|
||||||
|
одного лишь поля ввода: у логина начиналась после иконки пользователя, у пароля
|
||||||
|
обрывалась перед переключателем видимости, и ни одна её сторона не совпадала с
|
||||||
|
видимой границей поля.
|
||||||
|
|
||||||
|
Причина — специфичность, а не опечатка. Element Plus 2.14.5
|
||||||
|
(`theme-chalk/src/form-item.scss`) рисует состояние отказа на
|
||||||
|
`el-input__wrapper` селектором из ЧЕТЫРЁХ классов:
|
||||||
|
|
||||||
|
```text
|
||||||
|
.el-form-item.is-error .el-form-item__content .el-input__wrapper
|
||||||
|
```
|
||||||
|
|
||||||
|
Форма входа рисует видимую рамку поля на `el-form-item` — внутрь одного поля
|
||||||
|
кладутся иконка, ввод и переключатель видимости, — а штатную тень враппера
|
||||||
|
гасила селектором из трёх классов и проигрывала.
|
||||||
|
|
||||||
|
**Закрыто.** Индикация перенесена на элемент, который оператор и видит полем
|
||||||
|
(`.el-form-item.is-error`), а тень враппера гасится селектором, повторяющим
|
||||||
|
чужой и добавляющим атрибут scoped-стиля, — то есть выигрывает конкретностью, а
|
||||||
|
не `!important`. Сообщению об отказе оставлено место под полем:
|
||||||
|
`el-form-item__error` позиционируется абсолютно от `top: 100%` и живёт вне
|
||||||
|
рамки.
|
||||||
|
|
||||||
|
Проверены остальные формы панели: собственная рамка на `el-form-item` вместе с
|
||||||
|
переопределением `el-input__wrapper` встречается только на форме входа. Смена
|
||||||
|
пароля, диалог пира и тулбары используют штатную рамку, где `is-error` попадает
|
||||||
|
точно.
|
||||||
|
|
||||||
|
**Что машина не докажет.** Совпадение рамки с границей поля на экране остаётся
|
||||||
|
ручной проверкой; тест закрепляет только наличие правил, которые её
|
||||||
|
обеспечивают.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LOGIN-07 — класс символов записан диапазоном по опечатке
|
||||||
|
|
||||||
|
**Найдено при разборе панели.** В формах входа и смены пароля стояло
|
||||||
|
|
||||||
|
```text
|
||||||
|
/^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$/
|
||||||
|
```
|
||||||
|
|
||||||
|
Дефис внутри класса не экранирован, поэтому `+-=` образует ДИАПАЗОН и впускает
|
||||||
|
`, - . / 0-9 : ; < =`. С серверным набором это совпадало по совпадению: оба
|
||||||
|
несли одну и ту же опечатку. В форме пира тот же класс уже был записан явно
|
||||||
|
(`_+\-=`) — договорённость в проекте существовала и до входа не доехала.
|
||||||
|
|
||||||
|
**Закрыто.** Класс записан явно и НЕ сужен: фактическое множество уже действует
|
||||||
|
на установленных серверах. Экранирование дефиса закреплено тестом — пока набор
|
||||||
|
выглядел опечаткой, любая попытка «навести порядок» развела бы панель и сервер
|
||||||
|
обратно.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LOGIN-08 — время последнего входа не записывалось никогда
|
||||||
|
|
||||||
|
**Найдено при разборе.** Колонка `last_login_at` объявлена в схеме и в entity,
|
||||||
|
`service.UpdateAdminLastLoginAt` существовал — и не вызывался ниоткуда.
|
||||||
|
|
||||||
|
**Закрыто.** Отметка ставится в `service.Login`, сразу после успешной проверки
|
||||||
|
пароля: это единственная дверь, и записать её оттуда невозможно забыть. Отказ
|
||||||
|
записи вход НЕ отменяет — учётные данные уже подтверждены, — но пишется в
|
||||||
|
журнал уровнем error: неписаная отметка есть расхождение между тем, что
|
||||||
|
показывает панель, и тем, что произошло.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Отдельно: диагностика на хосте
|
||||||
|
|
||||||
|
`systemctl cat` открывает `less`, из-за чего вставленный следом блок
|
||||||
|
перемешивается с pager. Для воспроизводимых прогонов используется
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SYSTEMD_PAGER=cat systemctl cat hy2xs-admin.service
|
||||||
|
```
|
||||||
|
|
||||||
|
или `systemctl --no-pager cat hy2xs-admin.service`. Юнит `hy2xs-admin.service`
|
||||||
|
проверен и к дефекту отношения не имеет.
|
||||||
|
|
||||||
|
## Что делать с установленным RC2
|
||||||
|
|
||||||
|
На хосте ничего чинить вручную не нужно и не следует: hotpatch бинарника на
|
||||||
|
проде и перезалив содержимого уже опубликованного `v1.0.0-rc2` противоречат
|
||||||
|
воспроизводимости и immutable provenance, вокруг которых построен продукт.
|
||||||
|
Правильный путь — исправленный source и новая сборка, а хост переустанавливается
|
||||||
|
с нуля, чтобы проверка шла по тому же clean-host сценарию, а не поверх
|
||||||
|
установленного RC2.
|
||||||
@@ -25,12 +25,14 @@
|
|||||||
| Дата | Версия | Коммит источника | Вид | Вердикт |
|
| Дата | Версия | Коммит источника | Вид | Вердикт |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| 2026-09-01 | `1.0.0-rc1` | `a1f0db22` | build + host acceptance, Debian 13 | [RC ACCEPTED WITH RELEASE-REQUIRED UX FIXES](2026-09-01-v1.0.0-rc1-host-acceptance.md) |
|
| 2026-09-01 | `1.0.0-rc1` | `a1f0db22` | build + host acceptance, Debian 13 | [RC ACCEPTED WITH RELEASE-REQUIRED UX FIXES](2026-09-01-v1.0.0-rc1-host-acceptance.md) |
|
||||||
|
| 2026-09-04 | `1.0.0-rc2` | `82e5ca40` | clean install + runtime, Debian 13 | [RUNTIME REJECTED — вход в панель недоступен](2026-09-04-v1.0.0-rc2-runtime-findings.md) |
|
||||||
|
|
||||||
## Открытые дефекты приёмки
|
## Открытые дефекты приёмки
|
||||||
|
|
||||||
| Прогон | Дефекты |
|
| Прогон | Дефекты |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 2026-09-01, `1.0.0-rc1` | [UX-01…UX-05 и найденное сверх отчёта](2026-09-01-v1.0.0-rc1-ux-findings.md) |
|
| 2026-09-01, `1.0.0-rc1` | [UX-01…UX-05 и найденное сверх отчёта](2026-09-01-v1.0.0-rc1-ux-findings.md) |
|
||||||
|
| 2026-09-04, `1.0.0-rc2` | [LOGIN-01…LOGIN-08](2026-09-04-v1.0.0-rc2-runtime-findings.md) — все закрыты |
|
||||||
|
|
||||||
## Разборы кода между прогонами
|
## Разборы кода между прогонами
|
||||||
|
|
||||||
|
|||||||
@@ -259,3 +259,90 @@ control plane показывал всех пиров офлайн. Теперь
|
|||||||
принадлежит приложению. Он не является операторской настройкой: ни `hy2xs.env`,
|
принадлежит приложению. Он не является операторской настройкой: ни `hy2xs.env`,
|
||||||
ни config API, ни таблица `config`, ни настройки панели его не содержат и не
|
ни config API, ни таблица `config`, ни настройки панели его не содержат и не
|
||||||
могут переопределить.
|
могут переопределить.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Форма входа
|
||||||
|
|
||||||
|
**Правило.** Панель не имеет права быть строже сервера. Значение, которое
|
||||||
|
сервер принял бы, форма обязана отправить.
|
||||||
|
|
||||||
|
### Где живёт контракт
|
||||||
|
|
||||||
|
Требования к логину и паролю администратора объявлены **один раз**, в
|
||||||
|
`apps/service/admin_credentials.go`:
|
||||||
|
|
||||||
|
| Что | Значение | Владелец |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Длина логина | 6-32 символа | `AdminUsernameMinLength` / `AdminUsernameMaxLength` |
|
||||||
|
| Набор символов логина | `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=` | `AdminUsernameCharset` |
|
||||||
|
| Длина пароля | 6-64 символа | `AdminPasswordMinLength` / `AdminPasswordMaxLength` |
|
||||||
|
| Набор символов пароля | не ограничен | — |
|
||||||
|
|
||||||
|
Остальные три стороны продукта только повторяют этот контракт, и каждая копия
|
||||||
|
сверяется с оригиналом тестом, читающим Go-исходник:
|
||||||
|
|
||||||
|
* панель — `apps/frontend/src/constants/credentials.ts`
|
||||||
|
(`tools/test/frontend-contract.test.ts`);
|
||||||
|
* оркестратор — `orchestrator/src/config/profile.ts`
|
||||||
|
(`orchestrator/test/admin-credentials.test.ts`);
|
||||||
|
* правило валидатора — `credentialStr` в `apps/controller/validator.go`, длина
|
||||||
|
живёт ВНУТРИ него.
|
||||||
|
|
||||||
|
### Почему у пароля нет набора символов
|
||||||
|
|
||||||
|
Пароль назначает оператор — установкой через `HY2XS_ADMIN_INITIAL_PASSWORD` или
|
||||||
|
формой смены. Сервер его набор не проверяет нигде: значение сравнивается с
|
||||||
|
bcrypt-хешем. Ограничение набора на форме не защищает ничего и умеет только
|
||||||
|
отвергнуть пароль, который сервер принял бы.
|
||||||
|
|
||||||
|
Верхняя граница в 64 символа выбрана не круглым числом: bcrypt читает первые 72
|
||||||
|
БАЙТА и молча отбрасывает остаток, поэтому предел обязан быть заведомо ниже.
|
||||||
|
|
||||||
|
Длина считается в **символах**, а не в байтах: `go-playground/validator` считает
|
||||||
|
`min`/`max` на строке в рунах, и проверка по байтам отвергла бы пароль из 32
|
||||||
|
кириллических букв, который сервер принимает.
|
||||||
|
|
||||||
|
### Границы обеих форм обязаны совпадать
|
||||||
|
|
||||||
|
Форма входа и форма смены пароля предъявляют к паролю **одно и то же**
|
||||||
|
требование. Расхождение здесь запирает оператора снаружи после операции,
|
||||||
|
которую панель ему же и предложила: пароль длиннее предела формы входа
|
||||||
|
назначается успешно и после этого не вводится.
|
||||||
|
|
||||||
|
### Индикация ошибки принадлежит видимому полю
|
||||||
|
|
||||||
|
Element Plus рисует состояние отказа на `el-input__wrapper` правилом
|
||||||
|
|
||||||
|
```text
|
||||||
|
.el-form-item.is-error .el-form-item__content .el-input__wrapper
|
||||||
|
```
|
||||||
|
|
||||||
|
то есть селектором из четырёх классов. На форме входа видимое поле — это
|
||||||
|
`el-form-item`: внутрь одного поля кладутся иконка, ввод и переключатель
|
||||||
|
видимости пароля, а `el-input` занимает лишь среднюю часть. Поэтому штатная
|
||||||
|
индикация ложится вокруг одного лишь ввода и ни одной стороной не совпадает с
|
||||||
|
границей поля.
|
||||||
|
|
||||||
|
**Правило.** Там, где рамка поля нарисована на `el-form-item`, состояние отказа
|
||||||
|
рисуется на нём же, а штатная тень враппера гасится селектором, который
|
||||||
|
повторяет чужой и добавляет атрибут scoped-стиля — то есть выигрывает
|
||||||
|
специфичностью, а не `!important`. Сообщению об отказе оставляется место под
|
||||||
|
полем: `el-form-item__error` позиционируется абсолютно от `top: 100%` и живёт
|
||||||
|
вне рамки.
|
||||||
|
|
||||||
|
**Что машина не докажет.** Совпадение рамки с границей поля на экране. Проверка
|
||||||
|
остаётся ручной и фиксируется в отчёте приёмки; тест закрепляет только наличие
|
||||||
|
правил, которые её обеспечивают.
|
||||||
|
|
||||||
|
### Требование называется, а не нарушается
|
||||||
|
|
||||||
|
Фразы `credentials.usernameFormat` и `credentials.passwordLength` перечисляют
|
||||||
|
границы и набор символов. Набор логина приходит из `HY2XS_ADMIN_USER`, и
|
||||||
|
посмотреть его в панели больше негде — сообщение «Неверный формат логина» не
|
||||||
|
давало оператору ни одного способа узнать, что от него хотят.
|
||||||
|
|
||||||
|
Серверная причина `credential_format` несёт те же значения в `params`
|
||||||
|
(`min`, `max`, `charset`), и фраза панели обязана их использовать: правило одно
|
||||||
|
и проверяет и длину, и набор, поэтому описывать его только через символы —
|
||||||
|
значит описывать отказ по длине неверно.
|
||||||
|
|||||||
@@ -542,6 +542,44 @@ production-профилем, а не ищет подстроки. Проверя
|
|||||||
Сообщение об ошибке для `auth.http.url` намеренно не печатает сам токен: текст
|
Сообщение об ошибке для `auth.http.url` намеренно не печатает сам токен: текст
|
||||||
уходит в логи и в diagnostics-бандл.
|
уходит в логи и в diagnostics-бандл.
|
||||||
|
|
||||||
|
## Smoke проверяет, что панель ВПУСКАЕТ
|
||||||
|
|
||||||
|
Открытый порт — это не работающая панель.
|
||||||
|
|
||||||
|
До RC3 установка отвечала на вопрос «работает ли панель» тремя фактами: юнит
|
||||||
|
активен, `127.0.0.1:8080` в `LISTEN`, `/healthz` отвечает `ok: true`. RC2
|
||||||
|
доказал, что все три бывают истинными одновременно с полностью недоступной
|
||||||
|
панелью: на поле логина стоял тег незарегистрированного правила валидации,
|
||||||
|
`POST /api/auth/login` паниковал ещё до проверки учётных данных, `gin.Recovery`
|
||||||
|
превращал панику в HTTP 500 — и установка завершалась `INSTALL EXIT CODE: 0`.
|
||||||
|
|
||||||
|
Поэтому smoke выполняет **настоящий вход** на `POST /api/auth/login`:
|
||||||
|
|
||||||
|
| Проба | Когда | Что требуется |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| заведомо неверные учётные данные | всегда | HTTP 200 с конвертом отказа |
|
||||||
|
| bootstrap-учётные данные из `bootstrap-admin.secret` | только `install` | `code: 20000` и непустой `accessToken` |
|
||||||
|
|
||||||
|
Детали, которые здесь существенны:
|
||||||
|
|
||||||
|
- **успех определяется конвертом, а не кодом HTTP.** Админка отвечает `200 OK` и
|
||||||
|
на отказ тоже — причина живёт в поле `code`. Проверка «HTTP 200» приняла бы за
|
||||||
|
успешный вход любой отказ, то есть не проверяла бы ничего;
|
||||||
|
- **токен требуется отдельно.** `code: 20000` без `accessToken` означал бы
|
||||||
|
панель, которая пускает и не выдаёт сессию;
|
||||||
|
- **тело собирается `JSON.stringify`**, а не интерполяцией в строку: пароль
|
||||||
|
задаёт оператор, и кавычка в нём сломала бы сам запрос, а не панель — проверка
|
||||||
|
объявила бы рабочую установку сломанной;
|
||||||
|
- **обе команды идут через `runReadOnlySecret`**: он не кладёт команду в текст
|
||||||
|
ошибки, а команда несёт пароль администратора. Наружу отдаётся только код
|
||||||
|
ответа: тело успешного входа содержит токен доступа, а текст ошибки уезжает в
|
||||||
|
журнал установки и в diagnostics-бандл;
|
||||||
|
- **положительная проба install-only.** На `reconfigure` пароль в
|
||||||
|
`bootstrap-admin.secret` устаревает в тот момент, когда оператор сменил его в
|
||||||
|
панели, и требовать по нему вход значило бы ронять законную операцию.
|
||||||
|
Отрицательная проба от пароля не зависит и выполняется всегда — именно она
|
||||||
|
воспроизводит дефект RC2.
|
||||||
|
|
||||||
## Редактирование секретов
|
## Редактирование секретов
|
||||||
|
|
||||||
`redact-config` и diagnostics-бандл используют **структурную** редакцию: YAML
|
`redact-config` и diagnostics-бандл используют **структурную** редакцию: YAML
|
||||||
|
|||||||
@@ -144,6 +144,34 @@ anycast. Отсутствие A-записи фатально при любом
|
|||||||
- `HY2XS_FORCE_PASSWORD_CHANGE` в production baseline установлен в `false` (forced UX-flow пока не реализован);
|
- `HY2XS_FORCE_PASSWORD_CHANGE` в production baseline установлен в `false` (forced UX-flow пока не реализован);
|
||||||
- после первичного seed перезапуски `hy2xs-admin` не должны переопределять пароль admin и `con_pass`.
|
- после первичного seed перезапуски `hy2xs-admin` не должны переопределять пароль admin и `con_pass`.
|
||||||
|
|
||||||
|
### Учётные данные администратора проверяются при разборе окружения
|
||||||
|
|
||||||
|
`HY2XS_ADMIN_USER` и `HY2XS_ADMIN_INITIAL_PASSWORD` — это значения, которые
|
||||||
|
потом принимает **форма входа в панель**. Оркестратор проверяет их против того
|
||||||
|
же контракта, что и админка (`apps/service/admin_credentials.go`):
|
||||||
|
|
||||||
|
| Переменная | Требование | Значение по умолчанию |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `HY2XS_ADMIN_USER` | 6-32 символа из набора `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=` | `hy2xsadmin` |
|
||||||
|
| `HY2XS_ADMIN_INITIAL_PASSWORD` | 6-64 символа, набор не ограничен | генерируется |
|
||||||
|
|
||||||
|
Значение вне контракта **роняет установку** с явным текстом, называющим границы
|
||||||
|
и набор. Так и должно быть: отказ, пришедший установщику, чинится одной строкой
|
||||||
|
в `hy2xs.env`, а неработающий вход на готовом сервере — переустановкой.
|
||||||
|
|
||||||
|
Проверяется и сгенерированный пароль, а не только заданный оператором:
|
||||||
|
генератор — такой же источник значения.
|
||||||
|
|
||||||
|
Окружающие пробелы у `HY2XS_ADMIN_USER` снимаются. Иначе они уезжали бы в имя
|
||||||
|
учётной записи в SQLite, и вход отказывал бы «неверным логином или паролем» —
|
||||||
|
отказом, который невозможно связать с причиной.
|
||||||
|
|
||||||
|
Значение по умолчанию совпадает в трёх местах и обязано совпадать:
|
||||||
|
`package/config/hy2xs.env`, `orchestrator/src/config/env.ts` и запасное
|
||||||
|
значение в `apps/dao/sqlite.go`. Раньше оркестратор писал `admin` — пять
|
||||||
|
символов при минимуме панели в шесть, — и установка завершалась
|
||||||
|
`INSTALL EXIT CODE: 0`, оставляя панель, в которую невозможно войти.
|
||||||
|
|
||||||
### Immutable-bootstrap контракт
|
### Immutable-bootstrap контракт
|
||||||
|
|
||||||
- `/etc/hy2xs/bootstrap-admin.secret` создаётся оркестратором только при первичной установке.
|
- `/etc/hy2xs/bootstrap-admin.secret` создаётся оркестратором только при первичной установке.
|
||||||
|
|||||||
@@ -52,6 +52,38 @@
|
|||||||
32. дашборд различает «служба остановлена» и «состояние службы неизвестно»; доступность Traffic Stats API показывается независимо от ответа systemd
|
32. дашборд различает «служба остановлена» и «состояние службы неизвестно»; доступность Traffic Stats API показывается независимо от ответа systemd
|
||||||
33. страница журнала Hysteria показывает разобранные `level`/`time`/`msg` и структурный контекст, а не сырой JSON
|
33. страница журнала Hysteria показывает разобранные `level`/`time`/`msg` и структурный контекст, а не сырой JSON
|
||||||
34. страница конфигурации показывает фактические значения `/etc/hysteria/config.yaml`, перечисляет секции вне production-профиля и не содержит паролей и токенов
|
34. страница конфигурации показывает фактические значения `/etc/hysteria/config.yaml`, перечисляет секции вне production-профиля и не содержит паролей и токенов
|
||||||
|
35. **оператор входит в панель**: `POST /api/auth/login` с bootstrap-учётными данными из `/etc/hy2xs/bootstrap-admin.secret` отвечает `code: 20000` и непустым `accessToken`. Заведомо неверные учётные данные дают HTTP 200 с конвертом отказа, а не 500
|
||||||
|
36. пароль предельной длины (64 символа), назначенный формой смены пароля, принимается формой входа: границы обеих форм совпадают с серверными
|
||||||
|
37. `last_login_at` администратора обновляется после успешного входа и не меняется после неудачной попытки
|
||||||
|
|
||||||
|
## C0. Панель обязана впускать, а не слушать порт
|
||||||
|
|
||||||
|
Проверки 1-4 отвечают на вопрос «поднялось ли», и ни одна из них не отвечает на
|
||||||
|
вопрос «работает ли». RC2 показал разницу: юнит активен, `127.0.0.1:8080` в
|
||||||
|
`LISTEN`, `/healthz` отвечает `ok: true` — и `POST /api/auth/login` отдаёт
|
||||||
|
HTTP 500 на каждый запрос, потому что валидатор паникует на теге
|
||||||
|
несуществующего правила. Установка при этом завершилась `INSTALL EXIT CODE: 0`.
|
||||||
|
|
||||||
|
Поэтому вход в панель проверяется **настоящим запросом**, а не косвенными
|
||||||
|
признаками, и эта проверка встроена в smoke оркестратора — то есть релиз с
|
||||||
|
недоступной панелью физически не может завершиться успешной установкой.
|
||||||
|
Ручной эквивалент:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Пароль в переменную, чтобы он не попал ни в историю shell, ни в вывод.
|
||||||
|
read -r -s BOOTSTRAP_PASS < <(sudo grep '^ADMIN_INITIAL_PASSWORD=' /etc/hy2xs/bootstrap-admin.secret | cut -d= -f2-)
|
||||||
|
BOOTSTRAP_USER="$(sudo grep '^ADMIN_USER=' /etc/hy2xs/bootstrap-admin.secret | cut -d= -f2-)"
|
||||||
|
|
||||||
|
curl -sS --max-time 5 -X POST \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
--data "$(jq -nc --arg u "$BOOTSTRAP_USER" --arg p "$BOOTSTRAP_PASS" '{username:$u,pass:$p}')" \
|
||||||
|
http://127.0.0.1:8080/api/auth/login | jq '.code, (.data.accessToken | length)'
|
||||||
|
|
||||||
|
unset BOOTSTRAP_PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
Ожидается `20000` и ненулевая длина токена. Сам токен не печатается: это
|
||||||
|
действующая сессия администратора.
|
||||||
|
|
||||||
## C1. Семантический smoke конфига
|
## C1. Семантический smoke конфига
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import {
|
|||||||
GECKO_DEFAULT_MAX_PACKET_SIZE,
|
GECKO_DEFAULT_MAX_PACKET_SIZE,
|
||||||
GECKO_DEFAULT_MIN_PACKET_SIZE,
|
GECKO_DEFAULT_MIN_PACKET_SIZE,
|
||||||
HY2XS_CONFIG_SCHEMA_VERSION,
|
HY2XS_CONFIG_SCHEMA_VERSION,
|
||||||
|
assertValidAdminPassword,
|
||||||
|
assertValidAdminUsername,
|
||||||
normalizeHysteriaObfsType,
|
normalizeHysteriaObfsType,
|
||||||
validateGeckoPacketSizes
|
validateGeckoPacketSizes
|
||||||
} from "./profile";
|
} from "./profile";
|
||||||
@@ -250,8 +252,23 @@ export function parseRuntimeEnv(content: string): RuntimeConfig {
|
|||||||
uiBindHost,
|
uiBindHost,
|
||||||
uiPublicAccess: parseBool("HY2XS_UI_PUBLIC_ACCESS", env.HY2XS_UI_PUBLIC_ACCESS, false),
|
uiPublicAccess: parseBool("HY2XS_UI_PUBLIC_ACCESS", env.HY2XS_UI_PUBLIC_ACCESS, false),
|
||||||
uiPort,
|
uiPort,
|
||||||
adminUser: requireValue("HY2XS_ADMIN_USER", env.HY2XS_ADMIN_USER || "admin"),
|
// Умолчание — `hy2xsadmin`, и оно совпадает с package/config/hy2xs.env и с
|
||||||
adminInitialPassword: valueOrGenerate(env.HY2XS_ADMIN_INITIAL_PASSWORD),
|
// запасным значением в apps/dao/sqlite.go. Раньше здесь стояло `admin`:
|
||||||
|
// пять символов при минимуме панели в шесть, и третье расходящееся
|
||||||
|
// умолчание про одно и то же имя. Установка при этом завершалась успешно, а
|
||||||
|
// войти было нельзя — отказ приходил не установщику, а оператору, и уже без
|
||||||
|
// объяснения.
|
||||||
|
adminUser: assertValidAdminUsername(
|
||||||
|
"HY2XS_ADMIN_USER",
|
||||||
|
requireValue("HY2XS_ADMIN_USER", env.HY2XS_ADMIN_USER || "hy2xsadmin")
|
||||||
|
),
|
||||||
|
// Проверяется и сгенерированный пароль, а не только заданный оператором:
|
||||||
|
// генератор — такой же источник значения, и его расхождение с контрактом
|
||||||
|
// панели обязано ронять установку, а не всплывать на форме входа.
|
||||||
|
adminInitialPassword: assertValidAdminPassword(
|
||||||
|
"HY2XS_ADMIN_INITIAL_PASSWORD",
|
||||||
|
valueOrGenerate(env.HY2XS_ADMIN_INITIAL_PASSWORD)
|
||||||
|
),
|
||||||
adminConPass: requireValue("HY2XS_ADMIN_CON_PASS", valueOrGenerate(env.HY2XS_ADMIN_CON_PASS)),
|
adminConPass: requireValue("HY2XS_ADMIN_CON_PASS", valueOrGenerate(env.HY2XS_ADMIN_CON_PASS)),
|
||||||
forcePasswordChange: parseBool("HY2XS_FORCE_PASSWORD_CHANGE", env.HY2XS_FORCE_PASSWORD_CHANGE, false),
|
forcePasswordChange: parseBool("HY2XS_FORCE_PASSWORD_CHANGE", env.HY2XS_FORCE_PASSWORD_CHANGE, false),
|
||||||
allowSelfSignedDev: parseBool("HY2XS_ALLOW_SELF_SIGNED_DEV", env.HY2XS_ALLOW_SELF_SIGNED_DEV, false),
|
allowSelfSignedDev: parseBool("HY2XS_ALLOW_SELF_SIGNED_DEV", env.HY2XS_ALLOW_SELF_SIGNED_DEV, false),
|
||||||
|
|||||||
@@ -41,6 +41,89 @@ export const HY2XS_TARGET_ARCH = "amd64";
|
|||||||
export const ADMIN_API_BASE = "/api";
|
export const ADMIN_API_BASE = "/api";
|
||||||
export const HYSTERIA_MACHINE_AUTH_PATH = "/internal/hysteria/auth";
|
export const HYSTERIA_MACHINE_AUTH_PATH = "/internal/hysteria/auth";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Путь формы входа в панель. Смысл тот же, что у HYSTERIA_MACHINE_AUTH_PATH:
|
||||||
|
* это runtime-контракт продукта, по которому smoke проверяет, что установка
|
||||||
|
* оставила после себя РАБОТАЮЩУЮ панель, а не просто открытый порт.
|
||||||
|
*/
|
||||||
|
export const ADMIN_LOGIN_PATH = `${ADMIN_API_BASE}/auth/login`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Контракт учётных данных администратора.
|
||||||
|
*
|
||||||
|
* Зачем он здесь. Оркестратор задаёт имя и первый пароль администратора, а
|
||||||
|
* принимает их панель — по правилам, которых оркестратор не знал вовсе.
|
||||||
|
* Следствие было не теоретическим: значением по умолчанию здесь стояло
|
||||||
|
* `admin` — пять символов при минимуме в шесть, — и такая установка
|
||||||
|
* завершалась `INSTALL EXIT CODE: 0`, оставляя панель, в которую невозможно
|
||||||
|
* войти. Проверять контракт обязана та сторона, которая значение ПОРОЖДАЕТ:
|
||||||
|
* отказ установки чинится одной строкой в hy2xs.env, а неработающий вход на
|
||||||
|
* готовом сервере — переустановкой.
|
||||||
|
*
|
||||||
|
* Значения обязаны совпадать с apps/service/admin_credentials.go; сверка
|
||||||
|
* выполняется тестом admin-credentials.test.ts, который читает Go-исходник.
|
||||||
|
*/
|
||||||
|
export const ADMIN_USERNAME_MIN_LENGTH = 6;
|
||||||
|
export const ADMIN_USERNAME_MAX_LENGTH = 32;
|
||||||
|
export const ADMIN_PASSWORD_MIN_LENGTH = 6;
|
||||||
|
export const ADMIN_PASSWORD_MAX_LENGTH = 64;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Набор символов логина в записи регекспа.
|
||||||
|
*
|
||||||
|
* Дефис ЭКРАНИРОВАН намеренно. В исходной записи `_+-=` он экранирован не был,
|
||||||
|
* из-за чего `+-=` образовывал диапазон и молча впускал `, - . / 0-9 : ; < =`.
|
||||||
|
* Здесь перечислено то же самое ФАКТИЧЕСКОЕ множество, но явно: сужать его в
|
||||||
|
* одиночку нельзя — оно уже действует на установленных серверах.
|
||||||
|
*/
|
||||||
|
const ADMIN_USERNAME_CHARACTER_CLASS = "a-zA-Z0-9!@#$%^&*()_+,\\-./:;<=";
|
||||||
|
|
||||||
|
export const ADMIN_USERNAME_PATTERN = new RegExp(
|
||||||
|
`^[${ADMIN_USERNAME_CHARACTER_CLASS}]{${ADMIN_USERNAME_MIN_LENGTH},${ADMIN_USERNAME_MAX_LENGTH}}$`
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Тот же набор в том виде, в каком его показывают оператору. */
|
||||||
|
export const ADMIN_USERNAME_CHARSET = "a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверка логина администратора против контракта панели.
|
||||||
|
*
|
||||||
|
* Возвращает значение, а не булево: вызывающий обязан использовать именно
|
||||||
|
* проверенное — с обрезанными краями, — иначе пробел из hy2xs.env уедет в базу
|
||||||
|
* и вход снова перестанет работать по причине, которую негде увидеть.
|
||||||
|
*/
|
||||||
|
export function assertValidAdminUsername(name: string, value: string): string {
|
||||||
|
const username = value.trim();
|
||||||
|
if (!ADMIN_USERNAME_PATTERN.test(username)) {
|
||||||
|
throw new Error(
|
||||||
|
`invalid ${name}: панель принимает от ${ADMIN_USERNAME_MIN_LENGTH} до ${ADMIN_USERNAME_MAX_LENGTH} ` +
|
||||||
|
`символов из набора ${ADMIN_USERNAME_CHARSET}. ` +
|
||||||
|
`Установка с другим значением завершилась бы успешно, а войти в панель было бы нельзя.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return username;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверка пароля администратора против контракта панели.
|
||||||
|
*
|
||||||
|
* Набор символов НЕ проверяется: сервер его не ограничивает ни при установке,
|
||||||
|
* ни при смене пароля. Проверяется только длина — и в РУНАХ, ровно так её
|
||||||
|
* считает валидатор админки. `String.length` считает единицы UTF-16, и пароль
|
||||||
|
* из эмодзи прошёл бы здесь и отказался бы на форме входа.
|
||||||
|
*/
|
||||||
|
export function assertValidAdminPassword(name: string, value: string): string {
|
||||||
|
const length = [...value].length;
|
||||||
|
if (length < ADMIN_PASSWORD_MIN_LENGTH || length > ADMIN_PASSWORD_MAX_LENGTH) {
|
||||||
|
throw new Error(
|
||||||
|
`invalid ${name}: панель принимает пароль длиной от ${ADMIN_PASSWORD_MIN_LENGTH} ` +
|
||||||
|
`до ${ADMIN_PASSWORD_MAX_LENGTH} символов, получено ${length}. ` +
|
||||||
|
`Установка с другим значением завершилась бы успешно, а войти в панель было бы нельзя.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Где оркестратор живёт на установленном хосте.
|
* Где оркестратор живёт на установленном хосте.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { RuntimeContext } from "../types/context";
|
|||||||
import { info } from "../lib/log";
|
import { info } from "../lib/log";
|
||||||
import { readText } from "../lib/fs";
|
import { readText } from "../lib/fs";
|
||||||
import { runReadOnly, runReadOnlySecret, runMutatingVisible } from "../lib/process";
|
import { runReadOnly, runReadOnlySecret, runMutatingVisible } from "../lib/process";
|
||||||
import { HYSTERIA_MACHINE_AUTH_PATH, hysteriaMachineAuthUrl } from "../config/profile";
|
import { ADMIN_LOGIN_PATH, HYSTERIA_MACHINE_AUTH_PATH, hysteriaMachineAuthUrl } from "../config/profile";
|
||||||
import { assertHysteriaConfigMatchesProfile } from "./configAssertions";
|
import { assertHysteriaConfigMatchesProfile } from "./configAssertions";
|
||||||
import { assertEffectiveFirewallIsOurs } from "./firewall";
|
import { assertEffectiveFirewallIsOurs } from "./firewall";
|
||||||
|
|
||||||
@@ -209,6 +209,8 @@ export async function smoke(context: RuntimeContext): Promise<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await assertAdminLoginWorks(context);
|
||||||
|
|
||||||
await retry(
|
await retry(
|
||||||
"trafficStats valid secret",
|
"trafficStats valid secret",
|
||||||
10,
|
10,
|
||||||
@@ -244,6 +246,132 @@ export async function smoke(context: RuntimeContext): Promise<void> {
|
|||||||
await assertEffectiveHysteriaVersion(context);
|
await assertEffectiveHysteriaVersion(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Панель обязана ВПУСКАТЬ, а не просто слушать порт.
|
||||||
|
*
|
||||||
|
* Почему эта проверка появилась. До неё установка отвечала на вопрос «работает
|
||||||
|
* ли панель» тремя фактами: юнит активен, `127.0.0.1:8080` в LISTEN, `/healthz`
|
||||||
|
* отвечает `ok:true`. RC2 доказал, что все три могут быть истинными
|
||||||
|
* одновременно с полностью недоступной панелью: на поле логина стоял тег
|
||||||
|
* несуществующего правила валидации, `POST /api/auth/login` паниковал ещё до
|
||||||
|
* проверки учётных данных, gin.Recovery превращал панику в HTTP 500 — и
|
||||||
|
* установка завершалась `INSTALL EXIT CODE: 0`.
|
||||||
|
*
|
||||||
|
* Разница между «порт открыт» и «оператор может войти» — это весь продукт,
|
||||||
|
* поэтому smoke выполняет НАСТОЯЩИЙ вход теми учётными данными, которые создал
|
||||||
|
* установщик.
|
||||||
|
*
|
||||||
|
* Что здесь важно по деталям:
|
||||||
|
*
|
||||||
|
* - тело собирается JSON.stringify, а не интерполяцией в строку. Пароль
|
||||||
|
* задаёт оператор, и кавычка или обратный слеш в нём иначе сломали бы не
|
||||||
|
* панель, а сам запрос — и проверка объявила бы рабочую установку сломанной;
|
||||||
|
* - обе команды идут через runReadOnlySecret: этот раннер не кладёт команду в
|
||||||
|
* текст ошибки, а команда несёт пароль администратора. Ошибка проверки
|
||||||
|
* уезжает в журнал и в diagnostics-бандл;
|
||||||
|
* - положительная проба выполняется только на install. На reconfigure пароль в
|
||||||
|
* bootstrap-admin.secret устаревает в тот момент, когда оператор сменил его
|
||||||
|
* в панели, и требовать по нему вход значило бы ронять законную операцию;
|
||||||
|
* - отрицательная проба выполняется ВСЕГДА и от актуальности пароля не
|
||||||
|
* зависит. Именно она воспроизводит дефект RC2: заведомо неверные учётные
|
||||||
|
* данные обязаны получить осмысленный отказ, а не 500.
|
||||||
|
*/
|
||||||
|
async function assertAdminLoginWorks(context: RuntimeContext): Promise<void> {
|
||||||
|
const loginUrl = `http://127.0.0.1:${context.config.uiPort}${ADMIN_LOGIN_PATH}`;
|
||||||
|
|
||||||
|
const rejectedPayload = JSON.stringify({
|
||||||
|
username: "hy2xsadmin",
|
||||||
|
pass: "definitely-not-the-admin-password"
|
||||||
|
});
|
||||||
|
const rejectedStatus = await retry(
|
||||||
|
"admin login rejects wrong credentials",
|
||||||
|
10,
|
||||||
|
1000,
|
||||||
|
async () =>
|
||||||
|
runReadOnlySecret`curl -sS --max-time 5 -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' --data ${rejectedPayload} ${loginUrl}`,
|
||||||
|
(code) => code.trim() === "200",
|
||||||
|
(code, error) =>
|
||||||
|
new Error(
|
||||||
|
`admin login answered HTTP ${code ?? String(error)} for invalid credentials: ` +
|
||||||
|
`панель обязана отвечать конвертом отказа, а HTTP 500 здесь означает, что запрос ` +
|
||||||
|
`не доживает до проверки учётных данных`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
info(`admin login rejects wrong credentials with HTTP ${rejectedStatus.trim()}`);
|
||||||
|
|
||||||
|
if (context.mode !== "install") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminUser = (
|
||||||
|
await runReadOnlySecret`grep '^ADMIN_USER=' ${context.config.bootstrapAdminSecretPath} | head -n1 | cut -d= -f2-`
|
||||||
|
).trim();
|
||||||
|
const adminPassword = (
|
||||||
|
await runReadOnlySecret`grep '^ADMIN_INITIAL_PASSWORD=' ${context.config.bootstrapAdminSecretPath} | head -n1 | cut -d= -f2-`
|
||||||
|
).trim();
|
||||||
|
if (!adminUser) {
|
||||||
|
throw new Error("admin username is empty in bootstrap secret file");
|
||||||
|
}
|
||||||
|
if (!adminPassword) {
|
||||||
|
throw new Error("admin initial password is empty in bootstrap secret file");
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = JSON.stringify({ username: adminUser, pass: adminPassword });
|
||||||
|
const response = await retry(
|
||||||
|
"admin login with bootstrap credentials",
|
||||||
|
10,
|
||||||
|
1000,
|
||||||
|
async () =>
|
||||||
|
runReadOnlySecret`curl -sS --max-time 5 -X POST -H 'Content-Type: application/json' --data ${payload} ${loginUrl}`,
|
||||||
|
(body) => isSuccessfulLogin(body),
|
||||||
|
(body, error) =>
|
||||||
|
new Error(
|
||||||
|
`admin panel refused the bootstrap login it created itself: ${describeLoginFailure(body, error)}\n` +
|
||||||
|
`Порт открыт и /healthz отвечает, но войти в панель нельзя — установка не считается выполненной.`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
info(`admin login accepted: ${describeIssuedToken(response)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Успех определяется по КОНВЕРТУ, а не по коду HTTP.
|
||||||
|
*
|
||||||
|
* Админка отвечает `200 OK` и на отказ тоже: причина живёт в поле `code`
|
||||||
|
* ответа. Проверка «HTTP 200» приняла бы за успешный вход любой отказ — то есть
|
||||||
|
* ровно ничего бы не проверяла.
|
||||||
|
*
|
||||||
|
* Выданный токен требуется отдельно: `code: 20000` без `accessToken` означал бы
|
||||||
|
* панель, которая пускает и не выдаёт сессию.
|
||||||
|
*/
|
||||||
|
function isSuccessfulLogin(body: string): boolean {
|
||||||
|
return /"code"\s*:\s*20000/.test(body) && /"accessToken"\s*:\s*"[^"]+"/.test(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Причина отказа БЕЗ тела ответа.
|
||||||
|
*
|
||||||
|
* Тело сюда попасть не может: в ответе успешного входа лежит токен доступа, а
|
||||||
|
* текст этой ошибки уезжает в журнал установки и в diagnostics-бандл, который
|
||||||
|
* операторы пересылают в переписке. Поэтому наружу выдаётся только код ответа.
|
||||||
|
*/
|
||||||
|
function describeLoginFailure(body: string | undefined, error: unknown): string {
|
||||||
|
if (body === undefined) {
|
||||||
|
return `запрос не выполнен: ${String(error)}`;
|
||||||
|
}
|
||||||
|
const code = body.match(/"code"\s*:\s*(\d+)/);
|
||||||
|
if (code) {
|
||||||
|
return `ответ с code=${code[1]} и без токена доступа`;
|
||||||
|
}
|
||||||
|
return "ответ не является конвертом API админки";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Подтверждение выдачи токена без самого токена. */
|
||||||
|
function describeIssuedToken(body: string): string {
|
||||||
|
const tokenType = body.match(/"tokenType"\s*:\s*"([^"]*)"/);
|
||||||
|
return tokenType ? `выдан токен типа ${tokenType[1]}` : "выдан токен доступа";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Установленный бинарник обязан совпадать с версией, замороженной в metadata
|
* Установленный бинарник обязан совпадать с версией, замороженной в metadata
|
||||||
* пакета. На reconfigure metadata может относиться к другому пакету, поэтому
|
* пакета. На reconfigure metadata может относиться к другому пакету, поэтому
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { parseRuntimeEnv } from "../src/config/env";
|
||||||
|
import {
|
||||||
|
ADMIN_LOGIN_PATH,
|
||||||
|
ADMIN_PASSWORD_MAX_LENGTH,
|
||||||
|
ADMIN_PASSWORD_MIN_LENGTH,
|
||||||
|
ADMIN_USERNAME_CHARSET,
|
||||||
|
ADMIN_USERNAME_MAX_LENGTH,
|
||||||
|
ADMIN_USERNAME_MIN_LENGTH,
|
||||||
|
ADMIN_USERNAME_PATTERN
|
||||||
|
} from "../src/config/profile";
|
||||||
|
import { baselineConfig, envText } from "./fixtures";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Контракт учётных данных администратора на стороне оркестратора.
|
||||||
|
*
|
||||||
|
* Оркестратор ПОРОЖДАЕТ имя и первый пароль администратора, а принимает их
|
||||||
|
* панель — по правилам, которых он раньше не знал вовсе. RC2 показал, чем это
|
||||||
|
* заканчивается: установка завершается `INSTALL EXIT CODE: 0`, а войти в панель
|
||||||
|
* нельзя. Отказ обязан приходить установщику, которому он чинится одной строкой
|
||||||
|
* в hy2xs.env, а не оператору на форме входа, где чинить его уже нечем.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const REPO_ROOT = path.resolve(import.meta.dir, "..", "..");
|
||||||
|
|
||||||
|
function goContract(): string {
|
||||||
|
return fs.readFileSync(
|
||||||
|
path.join(REPO_ROOT, "apps", "service", "admin_credentials.go"),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function goConst(name: string): string {
|
||||||
|
const source = goContract();
|
||||||
|
const numeric = source.match(new RegExp(`\\b${name}\\s*=\\s*(\\d+)`));
|
||||||
|
if (numeric) {
|
||||||
|
return numeric[1];
|
||||||
|
}
|
||||||
|
const backticked = source.match(new RegExp(`\\b${name}\\s*=\\s*\`([^\`]*)\``));
|
||||||
|
if (backticked) {
|
||||||
|
return backticked[1];
|
||||||
|
}
|
||||||
|
throw new Error(`константа ${name} не найдена в apps/service/admin_credentials.go`);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("контракт учётных данных совпадает с админкой", () => {
|
||||||
|
// Копия контракта в TypeScript неизбежна: оркестратор собирается отдельно от
|
||||||
|
// Go-бинарника и импортировать его константы не может. Проверяется поэтому
|
||||||
|
// не отсутствие копии, а её совпадение с оригиналом — сверкой по исходнику.
|
||||||
|
test("границы длины совпадают с service.Admin*Length", () => {
|
||||||
|
expect(String(ADMIN_USERNAME_MIN_LENGTH)).toBe(goConst("AdminUsernameMinLength"));
|
||||||
|
expect(String(ADMIN_USERNAME_MAX_LENGTH)).toBe(goConst("AdminUsernameMaxLength"));
|
||||||
|
expect(String(ADMIN_PASSWORD_MIN_LENGTH)).toBe(goConst("AdminPasswordMinLength"));
|
||||||
|
expect(String(ADMIN_PASSWORD_MAX_LENGTH)).toBe(goConst("AdminPasswordMaxLength"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("набор символов логина совпадает с классом из Go", () => {
|
||||||
|
// Класс сравнивается как ЗНАЧЕНИЕ строки, а не как запись: в Go он живёт в
|
||||||
|
// raw-строке, в TypeScript — в обычной, где обратный слеш удваивается.
|
||||||
|
const declared = goConst("adminUsernameCharacterClass");
|
||||||
|
const mirrored = ADMIN_USERNAME_PATTERN.source.replace(
|
||||||
|
new RegExp(`^\\^\\[|\\]\\{${ADMIN_USERNAME_MIN_LENGTH},${ADMIN_USERNAME_MAX_LENGTH}\\}\\$$`, "g"),
|
||||||
|
""
|
||||||
|
);
|
||||||
|
expect(mirrored).toBe(declared);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("человекочитаемый набор совпадает с показываемым админкой", () => {
|
||||||
|
expect(ADMIN_USERNAME_CHARSET).toBe(goConst("AdminUsernameCharset"));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Дефис в классе символов ЭКРАНИРОВАН. Без экранирования `+-=` образует
|
||||||
|
// диапазон и молча впускает `, - . / 0-9 : ; < =` — ровно тот дефект, из-за
|
||||||
|
// которого фактическое множество разошлось с задуманным на всех четырёх
|
||||||
|
// сторонах продукта.
|
||||||
|
test("дефис в классе экранирован, а не образует диапазон", () => {
|
||||||
|
expect(ADMIN_USERNAME_PATTERN.source).toContain("\\-");
|
||||||
|
expect(ADMIN_USERNAME_PATTERN.source).not.toContain("+-=");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("путь формы входа собран из базового пространства имён API", () => {
|
||||||
|
expect(ADMIN_LOGIN_PATH).toBe("/api/auth/login");
|
||||||
|
|
||||||
|
const router = fs.readFileSync(path.join(REPO_ROOT, "apps", "router", "auth.go"), "utf8");
|
||||||
|
expect(router).toContain('auth.POST("/login"');
|
||||||
|
expect(router).toContain('authApi.Group("/auth")');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HY2XS_ADMIN_USER проверяется при разборе окружения", () => {
|
||||||
|
test("значение по умолчанию проходит контракт панели", () => {
|
||||||
|
const config = parseRuntimeEnv(envText({ HY2XS_ADMIN_USER: null }));
|
||||||
|
expect(config.adminUser).toBe("hy2xsadmin");
|
||||||
|
expect(ADMIN_USERNAME_PATTERN.test(config.adminUser)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Регрессия: умолчанием здесь стояло `admin` — пять символов при минимуме в
|
||||||
|
// шесть. Установка проходила целиком, а панель не впускала никого.
|
||||||
|
test("значение по умолчанию не короче минимума", () => {
|
||||||
|
const config = parseRuntimeEnv(envText({ HY2XS_ADMIN_USER: null }));
|
||||||
|
expect(config.adminUser.length).toBeGreaterThanOrEqual(ADMIN_USERNAME_MIN_LENGTH);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Умолчание обязано совпадать с запасным значением админки: иначе установка
|
||||||
|
// без явного HY2XS_ADMIN_USER создаёт одну учётную запись, а админка при
|
||||||
|
// повреждённом окружении ищет другую.
|
||||||
|
test("умолчание совпадает с запасным значением админки и с packaged env", () => {
|
||||||
|
const dao = fs.readFileSync(path.join(REPO_ROOT, "apps", "dao", "sqlite.go"), "utf8");
|
||||||
|
expect(dao).toContain('adminUser = "hy2xsadmin"');
|
||||||
|
|
||||||
|
const packaged = fs.readFileSync(
|
||||||
|
path.join(REPO_ROOT, "package", "config", "hy2xs.env"),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
expect(packaged).toContain("HY2XS_ADMIN_USER=hy2xsadmin");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("слишком короткий логин роняет установку", () => {
|
||||||
|
expect(() => baselineConfig({ HY2XS_ADMIN_USER: "admin" })).toThrow(
|
||||||
|
/invalid HY2XS_ADMIN_USER/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("слишком длинный логин роняет установку", () => {
|
||||||
|
expect(() =>
|
||||||
|
baselineConfig({ HY2XS_ADMIN_USER: "a".repeat(ADMIN_USERNAME_MAX_LENGTH + 1) })
|
||||||
|
).toThrow(/invalid HY2XS_ADMIN_USER/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("логин с пробелом, кириллицей или переводом строки роняет установку", () => {
|
||||||
|
for (const rejected of ["hy2xs admin", "админ12", "hy2xs\tadmin"]) {
|
||||||
|
expect(() => baselineConfig({ HY2XS_ADMIN_USER: rejected })).toThrow(
|
||||||
|
/invalid HY2XS_ADMIN_USER/
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("отказ называет и границы, и набор символов", () => {
|
||||||
|
expect(() => baselineConfig({ HY2XS_ADMIN_USER: "admin" })).toThrow(
|
||||||
|
new RegExp(`${ADMIN_USERNAME_MIN_LENGTH}.*${ADMIN_USERNAME_MAX_LENGTH}`)
|
||||||
|
);
|
||||||
|
expect(() => baselineConfig({ HY2XS_ADMIN_USER: "admin" })).toThrow(/a-z A-Z 0-9/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Значение уезжает в hy2xs.env и оттуда в базу. Пробел по краям превращался
|
||||||
|
// бы в часть имени учётной записи, и вход отказывал бы «неверным логином» —
|
||||||
|
// отказом, который невозможно связать с причиной.
|
||||||
|
test("окружающие пробелы снимаются, а не уезжают в имя учётной записи", () => {
|
||||||
|
expect(baselineConfig({ HY2XS_ADMIN_USER: " hy2xsadmin " }).adminUser).toBe("hy2xsadmin");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("точка в логине принимается: набор не сужается задним числом", () => {
|
||||||
|
expect(baselineConfig({ HY2XS_ADMIN_USER: "admin.ops" }).adminUser).toBe("admin.ops");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HY2XS_ADMIN_INITIAL_PASSWORD проверяется при разборе окружения", () => {
|
||||||
|
test("сгенерированный пароль проходит контракт панели", () => {
|
||||||
|
for (const raw of ["", "__GENERATE__"]) {
|
||||||
|
const config = parseRuntimeEnv(envText({ HY2XS_ADMIN_INITIAL_PASSWORD: raw }));
|
||||||
|
const length = [...config.adminInitialPassword].length;
|
||||||
|
expect(length).toBeGreaterThanOrEqual(ADMIN_PASSWORD_MIN_LENGTH);
|
||||||
|
expect(length).toBeLessThanOrEqual(ADMIN_PASSWORD_MAX_LENGTH);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("слишком короткий пароль роняет установку", () => {
|
||||||
|
expect(() => baselineConfig({ HY2XS_ADMIN_INITIAL_PASSWORD: "12345" })).toThrow(
|
||||||
|
/invalid HY2XS_ADMIN_INITIAL_PASSWORD/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("слишком длинный пароль роняет установку", () => {
|
||||||
|
expect(() =>
|
||||||
|
baselineConfig({
|
||||||
|
HY2XS_ADMIN_INITIAL_PASSWORD: "a".repeat(ADMIN_PASSWORD_MAX_LENGTH + 1)
|
||||||
|
})
|
||||||
|
).toThrow(/invalid HY2XS_ADMIN_INITIAL_PASSWORD/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Набор символов пароля сервером не ограничивается, и оркестратор не имеет
|
||||||
|
// права быть строже: отвергнутый здесь пароль сервер принял бы.
|
||||||
|
test("пароль из произвольных символов принимается", () => {
|
||||||
|
for (const password of ["p@ssw0rd?!", "пароль-администратора", "a b c d e f"]) {
|
||||||
|
expect(baselineConfig({ HY2XS_ADMIN_INITIAL_PASSWORD: password }).adminInitialPassword).toBe(
|
||||||
|
password
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Длина считается в РУНАХ: 32 кириллические буквы — это 64 байта, и проверка
|
||||||
|
// по байтам отвергла бы пароль, который панель принимает.
|
||||||
|
test("длина считается в символах, а не в байтах", () => {
|
||||||
|
const cyrillic = "я".repeat(ADMIN_PASSWORD_MAX_LENGTH);
|
||||||
|
expect(baselineConfig({ HY2XS_ADMIN_INITIAL_PASSWORD: cyrillic }).adminInitialPassword).toBe(
|
||||||
|
cyrillic
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("smoke выполняет настоящий вход в панель", () => {
|
||||||
|
const smoke = () =>
|
||||||
|
fs.readFileSync(path.join(REPO_ROOT, "orchestrator", "src", "steps", "smoke.ts"), "utf8");
|
||||||
|
|
||||||
|
// Главный барьер этого релиза. Пока установка отвечала на вопрос «работает ли
|
||||||
|
// панель» только фактом LISTEN и ответом /healthz, RC2 объявлял успешной
|
||||||
|
// установку с полностью недоступной панелью.
|
||||||
|
test("smoke обращается к форме входа, а не только к healthz", () => {
|
||||||
|
const source = smoke();
|
||||||
|
expect(source).toContain("ADMIN_LOGIN_PATH");
|
||||||
|
expect(source).toContain("assertAdminLoginWorks");
|
||||||
|
expect(source).toContain("isSuccessfulLogin");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("успех определяется конвертом ответа и выданным токеном", () => {
|
||||||
|
// Админка отвечает HTTP 200 и на отказ: проверка по коду HTTP приняла бы за
|
||||||
|
// успешный вход любой отказ.
|
||||||
|
const source = smoke();
|
||||||
|
expect(source).toMatch(/"code"\\s\*:\\s\*20000/);
|
||||||
|
expect(source).toMatch(/accessToken/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("учётные данные не попадают ни в команду ошибки, ни в сообщение", () => {
|
||||||
|
const source = smoke();
|
||||||
|
const start = source.indexOf("async function assertAdminLoginWorks");
|
||||||
|
expect(start).toBeGreaterThan(-1);
|
||||||
|
const body = source.slice(start, source.indexOf("\nfunction isSuccessfulLogin"));
|
||||||
|
|
||||||
|
// runReadOnly (без Secret) кладёт команду в текст ошибки, а команда несёт
|
||||||
|
// пароль администратора.
|
||||||
|
expect(body).not.toMatch(/(?<!Secret)runReadOnly`/);
|
||||||
|
expect(body).toMatch(/runReadOnlySecret`/);
|
||||||
|
|
||||||
|
// Подстановка в tagged template — это НЕ утечка: значения там проходят
|
||||||
|
// через shellQuote и в текст ошибки не попадают. Утечкой была бы подстановка
|
||||||
|
// секрета в сообщение — в info() или в new Error(), — потому что оно уезжает
|
||||||
|
// в журнал установки и в diagnostics-бандл.
|
||||||
|
const messages = [
|
||||||
|
...body.matchAll(/\binfo\(([\s\S]*?)\);/g),
|
||||||
|
...body.matchAll(/new Error\(([\s\S]*?)\);/g)
|
||||||
|
].map((match) => match[1]);
|
||||||
|
expect(messages.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
for (const message of messages) {
|
||||||
|
for (const secret of ["adminPassword", "payload", "response", "body}"]) {
|
||||||
|
expect(message).not.toContain(`\${${secret}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тело успешного ответа несёт токен доступа, поэтому наружу отдаётся только
|
||||||
|
// разбор ответа, а не он сам.
|
||||||
|
expect(body).toContain("describeLoginFailure(body, error)");
|
||||||
|
expect(body).toContain("describeIssuedToken(response)");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("тело запроса собирается JSON.stringify, а не интерполяцией", () => {
|
||||||
|
// Пароль задаёт оператор: кавычка или обратный слеш в нём сломали бы сам
|
||||||
|
// запрос, и проверка объявила бы рабочую установку сломанной.
|
||||||
|
const body = smoke();
|
||||||
|
expect(body).toContain("JSON.stringify({ username: adminUser, pass: adminPassword })");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("отрицательная проба не зависит от режима операции", () => {
|
||||||
|
// Пароль в bootstrap-admin.secret устаревает в момент, когда оператор сменил
|
||||||
|
// его в панели, поэтому положительная проба install-only. Отрицательная от
|
||||||
|
// пароля не зависит и обязана работать всегда: именно она воспроизводит
|
||||||
|
// дефект RC2.
|
||||||
|
const source = smoke();
|
||||||
|
const start = source.indexOf("async function assertAdminLoginWorks");
|
||||||
|
const body = source.slice(start, source.indexOf("\nfunction isSuccessfulLogin"));
|
||||||
|
|
||||||
|
const rejected = body.indexOf("admin login rejects wrong credentials");
|
||||||
|
const modeGate = body.indexOf('context.mode !== "install"');
|
||||||
|
expect(rejected).toBeGreaterThan(-1);
|
||||||
|
expect(modeGate).toBeGreaterThan(rejected);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -768,9 +768,102 @@ run_clean_install_acceptance() {
|
|||||||
run_legacy_account_acceptance
|
run_legacy_account_acceptance
|
||||||
run_scheduler_lifecycle_acceptance
|
run_scheduler_lifecycle_acceptance
|
||||||
run_dead_config_acceptance
|
run_dead_config_acceptance
|
||||||
|
run_admin_login_acceptance
|
||||||
run_dependency_hygiene_acceptance "$package_dir"
|
run_dependency_hygiene_acceptance "$package_dir"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Вход в панель: барьеры, без которых RC2 повторяется.
|
||||||
|
#
|
||||||
|
# Гейт существует по той же причине, что и гейт детектора гонок: проверки,
|
||||||
|
# закрывающие класс дефекта, не имеют права тихо исчезнуть. Удалённый тест
|
||||||
|
# оставляет сборку зелёной, а выпускаемый артефакт — неотличимым.
|
||||||
|
#
|
||||||
|
# Проверяется НАЛИЧИЕ барьеров, а не поведение: поведение доказывают сами
|
||||||
|
# тесты, и они обязательны (run_admin_tests, run_orchestrator_tests,
|
||||||
|
# run_frontend_tests). Здесь удерживается то, что их есть кому доказывать.
|
||||||
|
run_admin_login_acceptance() {
|
||||||
|
log_step "Acceptance: every validation tag is proven to exist before release"
|
||||||
|
|
||||||
|
# Дефект RC2 целиком: тег `validateStr` пережил переименование правила в
|
||||||
|
# `credentialStr`, валидатор паниковал на КАЖДОМ входе, gin.Recovery отдавал
|
||||||
|
# HTTP 500, установка при этом объявляла успех.
|
||||||
|
code_has apps/controller/auth_test.go -F -- 'func TestEveryValidationTagIsRegistered' \
|
||||||
|
|| fail "acceptance: барьер существования правил валидации удалён — stale-тег снова станет runtime-дефектом"
|
||||||
|
code_has apps/controller/auth_test.go -F -- 'validate.Var(' \
|
||||||
|
|| fail "acceptance: барьер обязан предъявлять тег валидатору, а не сравнивать строки"
|
||||||
|
|
||||||
|
# Наивная замена барьера не работает: цепочка правил поля обрывается на
|
||||||
|
# первом несработавшем, поэтому нулевое DTO отказывает по `required` и до
|
||||||
|
# испорченного тега не доходит. Барьер обязан ходить по ИСХОДНИКАМ.
|
||||||
|
code_has apps/controller/auth_test.go -F -- 'filepath.WalkDir' \
|
||||||
|
|| fail "acceptance: барьер перестал обходить исходники модели"
|
||||||
|
|
||||||
|
log_step "Acceptance: the admin credential contract has a single owner"
|
||||||
|
[ -f apps/service/admin_credentials.go ] \
|
||||||
|
|| fail "acceptance: контракт учётных данных администратора отсутствует"
|
||||||
|
code_has apps/controller/validator.go -F -- 'service.IsValidAdminUsername' \
|
||||||
|
|| fail "acceptance: правило логина снова несёт собственную копию набора символов"
|
||||||
|
|
||||||
|
# Длина логина живёт ВНУТРИ правила. Два правила длины на одном поле уже
|
||||||
|
# приводили к отказу, который невозможно объяснить оператору.
|
||||||
|
! code_has apps/model/dto/auth.go -E -- 'validate:"[^"]*(min|max)=[0-9]+[^"]*credentialStr' \
|
||||||
|
|| fail "acceptance: на логине снова стоит отдельная граница длины рядом с правилом"
|
||||||
|
|
||||||
|
log_step "Acceptance: the panel is never stricter than the server"
|
||||||
|
[ -f apps/frontend/src/constants/credentials.ts ] \
|
||||||
|
|| fail "acceptance: контракт учётных данных панели отсутствует"
|
||||||
|
local form
|
||||||
|
for form in apps/frontend/src/views/login/index.vue \
|
||||||
|
apps/frontend/src/views/admin/change-password/index.vue; do
|
||||||
|
code_has "$form" -F -- '@/constants/credentials' \
|
||||||
|
|| fail "acceptance: $form снова заводит собственное правило учётных данных"
|
||||||
|
# Неэкранированный дефис превращает `+-=` в диапазон: набор совпадал с
|
||||||
|
# серверным только потому, что обе стороны несли одну опечатку.
|
||||||
|
! code_has "$form" -F -- '_+-=' \
|
||||||
|
|| fail "acceptance: $form вернул класс символов, записанный диапазоном по опечатке"
|
||||||
|
done
|
||||||
|
|
||||||
|
log_step "Acceptance: install proves that the panel lets an operator in"
|
||||||
|
# `LISTEN` и `/healthz` не отвечают на вопрос, ради которого проверяются:
|
||||||
|
# в RC2 оба были истинны при полностью недоступной панели.
|
||||||
|
code_has orchestrator/src/steps/smoke.ts -F -- 'assertAdminLoginWorks' \
|
||||||
|
|| fail "acceptance: smoke больше не выполняет настоящий вход в панель"
|
||||||
|
code_has orchestrator/src/steps/smoke.ts -F -- 'ADMIN_LOGIN_PATH' \
|
||||||
|
|| fail "acceptance: адрес формы входа перестал браться из профиля"
|
||||||
|
# Успех определяется КОНВЕРТОМ: админка отвечает 200 OK и на отказ тоже.
|
||||||
|
code_has orchestrator/src/steps/smoke.ts -F -- '"code"\s*:\s*20000' \
|
||||||
|
|| fail "acceptance: успех входа снова определяется кодом HTTP, а не конвертом ответа"
|
||||||
|
# Команда несёт пароль администратора и не имеет права попасть в текст ошибки:
|
||||||
|
# runReadOnly (без Secret) кладёт её туда, а текст уезжает в журнал установки
|
||||||
|
# и в diagnostics-бандл. Проверяется ТЕЛО функции, а не файл: остальной smoke
|
||||||
|
# законно пользуется обоими раннерами.
|
||||||
|
local login_body
|
||||||
|
login_body="$(sed -n '/^async function assertAdminLoginWorks/,/^}/p' orchestrator/src/steps/smoke.ts)" \
|
||||||
|
|| fail "acceptance: не удалось прочитать проверку входа в панель"
|
||||||
|
[ -n "$login_body" ] \
|
||||||
|
|| fail "acceptance: функция проверки входа в панель не найдена"
|
||||||
|
grep -q 'runReadOnlySecret`curl' <<<"$login_body" \
|
||||||
|
|| fail "acceptance: запрос входа не выполняется секретным раннером"
|
||||||
|
# `runReadOnly`curl` совпадает только с ГОЛОЙ формой: у секретной после
|
||||||
|
# `runReadOnly` идёт `Secret`, а не обратная кавычка.
|
||||||
|
! grep -q 'runReadOnly`curl' <<<"$login_body" \
|
||||||
|
|| fail "acceptance: запрос входа выполняется раннером, кладущим команду в текст ошибки"
|
||||||
|
|
||||||
|
log_step "Acceptance: the orchestrator validates what it generates"
|
||||||
|
code_has orchestrator/src/config/env.ts -F -- 'assertValidAdminUsername(' \
|
||||||
|
|| fail "acceptance: HY2XS_ADMIN_USER снова не проверяется против контракта панели"
|
||||||
|
code_has orchestrator/src/config/env.ts -F -- 'assertValidAdminPassword(' \
|
||||||
|
|| fail "acceptance: HY2XS_ADMIN_INITIAL_PASSWORD снова не проверяется против контракта панели"
|
||||||
|
# Умолчание про одно и то же имя обязано быть одним. Их было три, и одно из
|
||||||
|
# них — `admin` — короче минимума панели.
|
||||||
|
code_has orchestrator/src/config/env.ts -F -- '"hy2xsadmin"' \
|
||||||
|
|| fail "acceptance: умолчание логина администратора разошлось с админкой и packaged env"
|
||||||
|
code_has apps/dao/sqlite.go -F -- 'adminUser = "hy2xsadmin"' \
|
||||||
|
|| fail "acceptance: запасное значение логина в админке разошлось с оркестратором"
|
||||||
|
grep -q '^HY2XS_ADMIN_USER=hy2xsadmin$' package/config/hy2xs.env \
|
||||||
|
|| fail "acceptance: packaged env разошёлся с умолчанием оркестратора"
|
||||||
|
}
|
||||||
|
|
||||||
# Планировщик принадлежит процессу и не перезапускает HTTP-сервер.
|
# Планировщик принадлежит процессу и не перезапускает HTTP-сервер.
|
||||||
run_scheduler_lifecycle_acceptance() {
|
run_scheduler_lifecycle_acceptance() {
|
||||||
log_step "Acceptance: cron scheduler is owned by the process, not by the HTTP server"
|
log_step "Acceptance: cron scheduler is owned by the process, not by the HTTP server"
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ import {
|
|||||||
FLAMY_NAME,
|
FLAMY_NAME,
|
||||||
FLAMY_URL,
|
FLAMY_URL,
|
||||||
} from "../../apps/frontend/src/constants/branding";
|
} from "../../apps/frontend/src/constants/branding";
|
||||||
|
import {
|
||||||
|
ADMIN_PASSWORD_MAX_LENGTH,
|
||||||
|
ADMIN_PASSWORD_MIN_LENGTH,
|
||||||
|
ADMIN_USERNAME_CHARSET,
|
||||||
|
ADMIN_USERNAME_MAX_LENGTH,
|
||||||
|
ADMIN_USERNAME_MIN_LENGTH,
|
||||||
|
ADMIN_USERNAME_PATTERN,
|
||||||
|
} from "../../apps/frontend/src/constants/credentials";
|
||||||
import ru from "../../apps/frontend/src/lang/package/ru";
|
import ru from "../../apps/frontend/src/lang/package/ru";
|
||||||
import en from "../../apps/frontend/src/lang/package/en";
|
import en from "../../apps/frontend/src/lang/package/en";
|
||||||
|
|
||||||
@@ -678,6 +686,160 @@ describe("правдивая диагностика", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("контракт учётных данных администратора", () => {
|
||||||
|
const goContract = () =>
|
||||||
|
fs.readFileSync(
|
||||||
|
path.join(REPO_ROOT, "apps", "service", "admin_credentials.go"),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
|
||||||
|
function goConst(name: string): string {
|
||||||
|
const source = goContract();
|
||||||
|
const numeric = source.match(new RegExp(`\\b${name}\\s*=\\s*(\\d+)`));
|
||||||
|
if (numeric) {
|
||||||
|
return numeric[1];
|
||||||
|
}
|
||||||
|
const backticked = source.match(new RegExp(`\\b${name}\\s*=\\s*\`([^\`]*)\``));
|
||||||
|
if (backticked) {
|
||||||
|
return backticked[1];
|
||||||
|
}
|
||||||
|
throw new Error(`константа ${name} не найдена в admin_credentials.go`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginForm = () => readSource("views", "login", "index.vue");
|
||||||
|
const changePasswordForm = () =>
|
||||||
|
readSource("views", "admin", "change-password", "index.vue");
|
||||||
|
|
||||||
|
// Панель не имеет права быть строже сервера: отвергнутое ею значение сервер
|
||||||
|
// принял бы, и оператор остаётся снаружи с паролем, который на самом деле
|
||||||
|
// подходит.
|
||||||
|
test("границы и набор символов совпадают с Go-контрактом", () => {
|
||||||
|
expect(String(ADMIN_USERNAME_MIN_LENGTH)).toBe(goConst("AdminUsernameMinLength"));
|
||||||
|
expect(String(ADMIN_USERNAME_MAX_LENGTH)).toBe(goConst("AdminUsernameMaxLength"));
|
||||||
|
expect(String(ADMIN_PASSWORD_MIN_LENGTH)).toBe(goConst("AdminPasswordMinLength"));
|
||||||
|
expect(String(ADMIN_PASSWORD_MAX_LENGTH)).toBe(goConst("AdminPasswordMaxLength"));
|
||||||
|
expect(ADMIN_USERNAME_CHARSET).toBe(goConst("AdminUsernameCharset"));
|
||||||
|
|
||||||
|
const declared = goConst("adminUsernameCharacterClass");
|
||||||
|
const mirrored = ADMIN_USERNAME_PATTERN.source
|
||||||
|
.replace(/^\^\[/, "")
|
||||||
|
.replace(new RegExp(`\\]\\{${ADMIN_USERNAME_MIN_LENGTH},${ADMIN_USERNAME_MAX_LENGTH}\\}\\$$`), "");
|
||||||
|
expect(mirrored).toBe(declared);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Регрессия RC2-UI-01. Дефис внутри класса символов не был экранирован, из-за
|
||||||
|
// чего `+-=` образовывал ДИАПАЗОН и впускал `, - . / 0-9 : ; < =`. Набор
|
||||||
|
// совпадал с серверным только по совпадению — оба несли одну и ту же
|
||||||
|
// опечатку, — и любая попытка «навести порядок» развела бы их обратно.
|
||||||
|
test("класс символов записан явно, а не диапазоном по опечатке", () => {
|
||||||
|
expect(ADMIN_USERNAME_PATTERN.source).toContain("\\-");
|
||||||
|
// Считается КОД: комментарий, объясняющий, почему прежней записи здесь
|
||||||
|
// больше нет, обязан называть её по имени — иначе он бесполезен. Это тот же
|
||||||
|
// урок, что записан в vueCodeOf этажом выше, и он повторился прямо здесь.
|
||||||
|
for (const form of [loginForm(), changePasswordForm()]) {
|
||||||
|
expect(vueCodeOf(form)).not.toContain("_+-=");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Правило объявлено ОДИН раз. Пока каждая форма несла своё, они разошлись и с
|
||||||
|
// сервером, и друг с другом.
|
||||||
|
test("обе формы берут правило из общего модуля, а не заводят своё", () => {
|
||||||
|
for (const form of [loginForm(), changePasswordForm()]) {
|
||||||
|
expect(form).toContain("@/constants/credentials");
|
||||||
|
// Собственных регекспов и собственных чисел в формах быть не должно.
|
||||||
|
expect(vueCodeOf(form)).not.toMatch(/pattern\s*[:=]\s*\//);
|
||||||
|
expect(vueCodeOf(form)).not.toMatch(/\{\s*6\s*,\s*\d+\s*\}/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Регрессия RC2-UI-02: форма входа ограничивала пароль 32 символами при
|
||||||
|
// серверном пределе в 64, а форма смены пароля назначала до 64. Пароль,
|
||||||
|
// назначенный штатной операцией, становился паролем, с которым невозможно
|
||||||
|
// войти.
|
||||||
|
test("границы пароля на обеих формах — одни и те же", () => {
|
||||||
|
for (const form of [loginForm(), changePasswordForm()]) {
|
||||||
|
const code = vueCodeOf(form);
|
||||||
|
expect(code).toContain("min: ADMIN_PASSWORD_MIN_LENGTH");
|
||||||
|
expect(code).toContain("max: ADMIN_PASSWORD_MAX_LENGTH");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Набор символов пароля сервером не проверяется НИГДЕ, поэтому его проверка в
|
||||||
|
// панели умеет только отвергнуть пароль, который сервер принял бы.
|
||||||
|
test("на пароль не навешен набор символов", () => {
|
||||||
|
for (const form of [loginForm(), changePasswordForm()]) {
|
||||||
|
const code = vueCodeOf(form);
|
||||||
|
expect(code).not.toContain("ADMIN_USERNAME_PATTERN,\n message: t(\"credentials.passwordLength\"");
|
||||||
|
expect(code).not.toMatch(/passwordPattern/);
|
||||||
|
}
|
||||||
|
// Правило логина — единственное место, где вообще упоминается набор.
|
||||||
|
expect(vueCodeOf(changePasswordForm())).not.toContain("ADMIN_USERNAME_PATTERN");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Требование НАЗЫВАЕТСЯ, а не сообщается фактом нарушения: набор символов
|
||||||
|
// логина приходит из hy2xs.env, и посмотреть его в панели больше негде.
|
||||||
|
test("фразы называют границы и набор, и есть в обоих словарях", () => {
|
||||||
|
for (const dictionary of [ru, en] as const) {
|
||||||
|
const credentials = (dictionary as any).credentials ?? {};
|
||||||
|
expect(typeof credentials.usernameFormat).toBe("string");
|
||||||
|
expect(typeof credentials.passwordLength).toBe("string");
|
||||||
|
|
||||||
|
for (const placeholder of ["{min}", "{max}", "{charset}"]) {
|
||||||
|
expect(credentials.usernameFormat).toContain(placeholder);
|
||||||
|
}
|
||||||
|
for (const placeholder of ["{min}", "{max}"]) {
|
||||||
|
expect(credentials.passwordLength).toContain(placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Причина отказа от сервера несёт те же params и обязана их использовать.
|
||||||
|
const reason = (dictionary as any).error?.code?.credential_format ?? "";
|
||||||
|
for (const placeholder of ["{min}", "{max}", "{charset}"]) {
|
||||||
|
expect(reason).toContain(placeholder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("индикация ошибки на форме входа", () => {
|
||||||
|
const loginForm = () => readSource("views", "login", "index.vue");
|
||||||
|
|
||||||
|
// Регрессия RC2-UI-03, видимая на скриншоте формы.
|
||||||
|
//
|
||||||
|
// Element Plus рисует состояние отказа на `el-input__wrapper` селектором
|
||||||
|
//
|
||||||
|
// .el-form-item.is-error .el-form-item__content .el-input__wrapper
|
||||||
|
//
|
||||||
|
// то есть из ЧЕТЫРЁХ классов. Форма входа рисует видимую рамку поля на
|
||||||
|
// `el-form-item` — потому что в поле лежат ещё иконка и переключатель
|
||||||
|
// видимости пароля, — а тень враппера гасила селектором из трёх классов и
|
||||||
|
// проигрывала по специфичности. Красная рамка ложилась вокруг одного лишь
|
||||||
|
// поля ввода: у логина начиналась после иконки, у пароля обрывалась перед
|
||||||
|
// «глазом».
|
||||||
|
test("отказ подсвечивает поле целиком, а не один только ввод", () => {
|
||||||
|
const styles = loginForm();
|
||||||
|
|
||||||
|
// Индикация переехала на элемент, который оператор и видит полем.
|
||||||
|
expect(styles).toMatch(/&\.is-error\s*\{[\s\S]*?border-color:\s*var\(--el-color-danger\)/);
|
||||||
|
|
||||||
|
// Штатная тень враппера гасится селектором, который повторяет чужой и
|
||||||
|
// добавляет атрибут scoped-стиля, — то есть выигрывает по специфичности.
|
||||||
|
expect(styles).toContain(":deep(.el-form-item__content .el-input__wrapper)");
|
||||||
|
|
||||||
|
// Победа специфичностью, а не силой: `!important` перекрыл бы и те
|
||||||
|
// состояния, о которых правило ничего не знает.
|
||||||
|
expect(styles).not.toContain("box-shadow: none !important");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Сообщение об отказе позиционируется абсолютно от `top: 100%`, то есть
|
||||||
|
// висит ВНЕ рамки. При стандартных 18px оно прижималось к границе снизу и к
|
||||||
|
// следующему полю сверху и выглядело принадлежащим не тому полю.
|
||||||
|
test("сообщению об отказе оставлено место под полем", () => {
|
||||||
|
const styles = loginForm();
|
||||||
|
expect(styles).toContain(":deep(.el-form-item__error)");
|
||||||
|
expect(styles).toMatch(/margin-bottom:\s*(1[9]|[2-9]\d)px/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("подсказки формы пира", () => {
|
describe("подсказки формы пира", () => {
|
||||||
// Подсказка обещала более узкий набор символов, чем принимает сервер: она
|
// Подсказка обещала более узкий набор символов, чем принимает сервер: она
|
||||||
// осталась от правила, действовавшего до EX-03. Оператор не имел причин
|
// осталась от правила, действовавшего до EX-03. Оператор не имел причин
|
||||||
|
|||||||
Reference in New Issue
Block a user