fix(admin): закрыть обещания панели, которые продукт не выполнял
Девятый проход, по итогам приёмки v1.0.0-rc1 на живом Debian 13. Общая тема:
интерфейс обещал оператору то, что продукт умел, но до чего не доходило
управление.
Секрет пира. Подпись под полем предлагала оставить его пустым, сервер умел его
сгенерировать, и генерация была недостижима: в go-playground/validator тег
omitempty НЕ пропускает правило, если поле объявлено указателем и указатель не
nil — hasValue считает указатель на пустую строку «значением». Правило min=6
применялось к пустой строке и отказывало. Ловушка закрыта общим шагом
нормализации DTO, а не тегом на одном поле: та же ловушка ломала фильтр списка
пиров, где очищенный крестиком el-input отправляет `?name=`. Граница проходит по
каждому полю отдельно — у remark пустая строка означает «убрать пометку», у
disabled ноль означает «включён».
Отказы. Любая ошибка любого поля превращалась в слово `invalid`, а слой vo
определял код ответа СРАВНЕНИЕМ текста сообщения — тот же антипаттерн, который
запрещён панели, только на сервере. Ответ несёт errors[{code, field, message,
params}]; панель выбирает фразу по коду и подставляет причины под поля.
Сессия. Ветка «войдите заново» была недостижима дважды: сервер отвечает HTTP 200
на любой отказ, поэтому обработчик ошибок axios не вызывался, а условие в нём
проверяло code === "A0230" и поле msg, которых в этом API никогда не было.
Истёкший токен вдобавок уезжал с кодом системной ошибки.
Иконки. Контракт currentColor был объявлен в двух местах и не действовал: восемь
ассетов несли литеральный fill="#000000" на <path>, а атрибут представления
перебивает унаследованное CSS-свойство. Под это попадали все семь иконок
бокового меню на фоне #181818.
Имя пира. Два правила на одном поле противоречили друг другу (min=1 против
6-32), а копия набора символов в слое контроллеров несла неэкранированный дефис
и впускала `, - . / : ; <` — через панель проходило имя peer/name, которое
импорт того же пира отклонял. Набор символов ЛОГИНА сознательно не сужен и
закреплён тестом: он приходит из HY2XS_ADMIN_USER и оркестратором не
ограничивается.
Добавлены подпись «Разработано во Flamy» с адресом, принадлежащим приложению, и
контрактные тесты панели как обязательный шаг сборки. Их исполняет Bun, а не
vitest: jsdom не вычисляет currentColor и визуальной корректности не доказал бы,
зато vitest привёл бы в граф pnpm audit сотню транзитивных зависимостей.
docs/ разложена по слоям, 11-testing-and-acceptance.md (117 КБ) разбит на пять
частей, добавлен docs/acceptance/ с отчётом о прогоне rc1 и перечнем дефектов.
Обход документации в приёмке стал рекурсивным: плоский docs/*.md после
разнесения по каталогам совпадал бы ровно с одним файлом.
This commit is contained in:
+139
-23
@@ -219,7 +219,7 @@ run_fix20_acceptance_subset() {
|
||||
grep -q '^Environment=GIN_MODE=release$' "$package_dir/systemd/hy2xs-admin.service" || fail "acceptance: GIN_MODE=release missing"
|
||||
|
||||
log_step "Acceptance: docs matrix markers"
|
||||
grep -q 'Fix20 production matrix' docs/11-testing-and-acceptance.md || fail "acceptance: fix20 matrix section missing"
|
||||
grep -q 'Fix20 production matrix' docs/testing/11-5-negative-and-matrix.md || fail "acceptance: fix20 matrix section missing"
|
||||
|
||||
log_step "Acceptance: machine auth URL in templates"
|
||||
grep -q '/internal/hysteria/auth?access_token={{HYSTERIA_API_SECRET}}' "$package_dir/templates/hysteria/config.yaml.tpl" || fail "acceptance: machine token missing in hysteria auth URL template"
|
||||
@@ -295,30 +295,128 @@ run_fix20_acceptance_subset() {
|
||||
# разрешается резолвером, и сборка bundle падает — то есть проверяется
|
||||
# исполняемый импорт, а не совпадение подстроки.
|
||||
|
||||
# Каждая иконка обязана давать symbol с viewBox.
|
||||
# Контракт ассета проверяется ОБЩЕЙ функцией, а не второй её копией здесь.
|
||||
#
|
||||
# Без viewBox `<use>` не знает систему координат и рисует иконку в натуральную
|
||||
# величину, обрезая её по размеру родительского svg. Три иконки из
|
||||
# семнадцати его не объявляют — для них viewBox синтезируется из width/height,
|
||||
# как это делал заменённый плагин. Проверка сторожит именно ассеты: иконка,
|
||||
# добавленная без обоих способов задать координаты, иначе сломала бы
|
||||
# отрисовку молча.
|
||||
# Раньше в этом месте лежала самостоятельная реализация проверки viewBox —
|
||||
# построчный двойник кода из sprite.ts. Пока проверялась одна вещь, это
|
||||
# выглядело безобидно; с добавлением проверки цвета две копии правил
|
||||
# разошлись бы так же, как разошлись две копии правила имени пира. Теперь
|
||||
# findIconContractViolations живёт рядом с кодом, который строит symbol, и
|
||||
# зовётся отсюда.
|
||||
#
|
||||
# Проверяется: система координат (без viewBox `<use>` рисует иконку в
|
||||
# натуральную величину и обрезает её), отсутствие литеральных цветов у
|
||||
# монохромных иконок, отсутствие инлайнового style и непустого <style>,
|
||||
# отсутствие растра.
|
||||
"$BUN_BIN" -e '
|
||||
const fs = require("node:fs");
|
||||
const { findIconContractViolations, iconName } =
|
||||
await import("./apps/frontend/src/components/SvgIcon/symbol.ts");
|
||||
const dir = "apps/frontend/src/assets/icons";
|
||||
const files = fs.readdirSync(dir).filter((f) => f.endsWith(".svg"));
|
||||
if (files.length === 0) throw new Error("каталог локальных иконок пуст");
|
||||
const broken = [];
|
||||
for (const file of files) {
|
||||
const raw = fs.readFileSync(`${dir}/${file}`, "utf8");
|
||||
const openTag = raw.replace(/<\?xml[\s\S]*?\?>/gi, "").replace(/<!DOCTYPE[\s\S]*?>/gi, "").match(/<svg\b[^>]*>/i);
|
||||
if (!openTag) { broken.push(`${file}: нет корневого <svg>`); continue; }
|
||||
const hasViewBox = /viewBox="[^"]+"/i.test(openTag[0]);
|
||||
const hasSize = /width="[\d.]+[a-z%]*"/i.test(openTag[0]) && /height="[\d.]+[a-z%]*"/i.test(openTag[0]);
|
||||
if (!hasViewBox && !hasSize) broken.push(`${file}: нет ни viewBox, ни пары width/height`);
|
||||
broken.push(...findIconContractViolations(fs.readFileSync(`${dir}/${file}`, "utf8"), iconName(file)));
|
||||
}
|
||||
if (broken.length) throw new Error("иконки без системы координат:\n" + broken.join("\n"));
|
||||
' || fail "acceptance: локальная иконка не даёт корректный <symbol> для спрайта"
|
||||
if (broken.length) throw new Error("иконки не соответствуют контракту спрайта:\n" + broken.join("\n"));
|
||||
' || fail "acceptance: локальная иконка не соответствует контракту спрайта"
|
||||
|
||||
log_step "Acceptance: icon colour is inherited, not patched per icon"
|
||||
# Прямой запрет из требований к исправлению: маскировать дефект конвейера
|
||||
# отрисовки фильтром или перекрашивать иконку по её имени нельзя.
|
||||
#
|
||||
# Ищется СИНТАКСИЧЕСКАЯ ФОРМА: правило для .svg-icon с filter и селектор по
|
||||
# атрибуту icon-class. Комментарий, объясняющий, почему их нет, обязан
|
||||
# называть их по имени.
|
||||
# Правило может занимать несколько строк, поэтому проверка читает файл
|
||||
# целиком, а не построчно: grep здесь дал бы ложный PASS на любом
|
||||
# отформатированном CSS.
|
||||
"$BUN_BIN" -e '
|
||||
const fs = require("node:fs");
|
||||
const files = fs.readdirSync("apps/frontend/src/styles")
|
||||
.map((f) => `apps/frontend/src/styles/${f}`)
|
||||
.concat(["apps/frontend/src/components/SvgIcon/index.vue"]);
|
||||
const offenders = [];
|
||||
for (const file of files) {
|
||||
const source = fs.readFileSync(file, "utf8");
|
||||
if (/\.svg-icon[^{]*\{[^}]*\bfilter\s*:/i.test(source)) {
|
||||
offenders.push(`${file}: цвет иконок маскируется CSS-фильтром`);
|
||||
}
|
||||
if (/\[icon-class[~^*$|]?=/i.test(source)) {
|
||||
offenders.push(`${file}: цвет задаётся по имени конкретной иконки`);
|
||||
}
|
||||
}
|
||||
if (offenders.length) throw new Error(offenders.join("\n"));
|
||||
' || fail "acceptance: дефект отрисовки иконок замаскирован вместо исправления"
|
||||
# Наследование обязано быть объявлено: ассет без литерального цвета сам по
|
||||
# себе цвета не даёт, он его получает от компонента.
|
||||
grep -q 'fill: currentcolor' apps/frontend/src/components/SvgIcon/index.vue \
|
||||
|| fail "acceptance: SvgIcon больше не наследует цвет через currentColor"
|
||||
# Проп цвета у компонента приглашал чинить отрисовку точечно, в обход общего
|
||||
# контракта. Проверяется форма: атрибут fill на элементе use.
|
||||
! grep -qE '<use[^>]*\bfill\s*=' apps/frontend/src/components/SvgIcon/index.vue \
|
||||
|| fail "acceptance: SvgIcon снова принимает цвет параметром"
|
||||
|
||||
log_step "Acceptance: peer secret generation belongs to the server"
|
||||
# «Оставьте пустым — сгенерируем автоматически» обязано выполняться для всех
|
||||
# дверей: панели, прямого вызова API, импорта и будущих клиентов. Генерация
|
||||
# во frontend выполняла бы обещание ровно для одной из них.
|
||||
code_has apps/service/peer_secret.go -F -- 'func GeneratePeerSecret' \
|
||||
|| fail "acceptance: генерация секрета пира не объявлена на сервисном слое"
|
||||
code_has apps/service/peer_secret.go -F -- 'util.RandomString' \
|
||||
|| fail "acceptance: генерация секрета пира не использует общий крипто-генератор"
|
||||
# Импорт без секрета обязан давать пира, неотличимого от созданного формой.
|
||||
code_has apps/service/peer.go -F -- 'GeneratePeerSecret(name)' \
|
||||
|| fail "acceptance: импорт пиров генерирует секрет собственным способом"
|
||||
local frontend_rng_hits
|
||||
frontend_rng_hits="$(grep -rlE 'crypto\.getRandomValues|Math\.random' apps/frontend/src || true)"
|
||||
[ -z "$frontend_rng_hits" ] \
|
||||
|| fail "acceptance: панель генерирует случайные значения сама; секреты пиров создаёт сервер. Найдено:
|
||||
$frontend_rng_hits"
|
||||
|
||||
log_step "Acceptance: validation failures name the field and the rule"
|
||||
# Раньше и разбор тела, и нарушение любого правила любого поля превращались в
|
||||
# одно слово `invalid`, а слой vo выбирал HTTP-семантику СРАВНЕНИЕМ текста
|
||||
# сообщения с тремя литералами. Панель не могла ни подсветить поле, ни
|
||||
# локализовать причину, не разбирая прозу.
|
||||
code_has apps/controller/validator.go -F -- 'vo.FailValidation' \
|
||||
|| fail "acceptance: отказ валидации перестал быть структурированным"
|
||||
code_has apps/controller/validator.go -F -- 'describeValidationErrors' \
|
||||
|| fail "acceptance: причины отказа валидации не раскладываются по полям"
|
||||
code_has apps/model/vo/result.go -F -- 'Errors []FieldError' \
|
||||
|| fail "acceptance: ответ об ошибке больше не несёт машиночитаемых причин"
|
||||
# Классификация по тексту сообщения не должна вернуться: ищется форма
|
||||
# сравнения, а не упоминание — комментарий выше в result.go обязан называть
|
||||
# убранный приём по имени.
|
||||
! code_has apps/model/vo/result.go -E -- 'constant\.[A-Za-z]+Error *== *message' \
|
||||
|| fail "acceptance: код ответа снова выводится сравнением текста сообщения"
|
||||
local generic_invalid_hits
|
||||
generic_invalid_hits="$(code_mentions_in 'vo.Fail(constant.InvalidError' apps/controller apps/middleware)"
|
||||
[ -z "$generic_invalid_hits" ] \
|
||||
|| fail "acceptance: обобщённый отказ «invalid» вернулся в: $generic_invalid_hits"
|
||||
# Панель обязана выбирать фразу по коду, а не по тексту ответа.
|
||||
code_has apps/frontend/src/utils/api-message.ts -F -- 'error.code.' \
|
||||
|| fail "acceptance: панель не локализует причины отказа по коду"
|
||||
|
||||
log_step "Acceptance: Flamy attribution is application-owned, not operator config"
|
||||
# Оператор HY2XS не должен иметь возможности переназначить, куда ведёт
|
||||
# подпись разработчика: ни через панель, ни через hy2xs.env, ни через таблицу
|
||||
# `config`.
|
||||
[ -f apps/frontend/src/constants/branding.ts ] \
|
||||
|| fail "acceptance: внутренние константы бренда отсутствуют"
|
||||
code_has apps/frontend/src/constants/branding.ts -F -- 'https://flamy.studio' \
|
||||
|| fail "acceptance: адрес атрибуции не объявлен в константах бренда"
|
||||
local flamy_carriers
|
||||
flamy_carriers="$(grep -rl 'flamy\.studio' apps/frontend/src || true)"
|
||||
[ "$flamy_carriers" = "apps/frontend/src/constants/branding.ts" ] \
|
||||
|| fail "acceptance: адрес атрибуции размазан по исходникам панели: $flamy_carriers"
|
||||
local flamy_config_hits
|
||||
flamy_config_hits="$(grep -ril 'flamy' \
|
||||
package/config package/templates orchestrator/src \
|
||||
apps/model/constant apps/dao || true)"
|
||||
[ -z "$flamy_config_hits" ] \
|
||||
|| fail "acceptance: адрес атрибуции стал операторской настройкой. Найдено в: $flamy_config_hits"
|
||||
|
||||
log_step "Acceptance: frontend i18n does not touch Pinia at module import"
|
||||
! grep -q 'useAppStore' apps/frontend/src/lang/index.ts || fail "acceptance: lang/index.ts must not import/use Pinia store"
|
||||
@@ -631,7 +729,7 @@ run_clean_install_acceptance() {
|
||||
|
||||
log_step "Acceptance: legacy cleanup is a separate, explicit helper"
|
||||
[ -f tools/legacy/purge-v0.sh ] || fail "acceptance: legacy cleanup helper is missing"
|
||||
[ -f docs/14-legacy-cleanup.md ] || fail "acceptance: legacy cleanup runbook is missing"
|
||||
[ -f docs/operations/14-legacy-cleanup.md ] || fail "acceptance: legacy cleanup runbook is missing"
|
||||
! grep -q 'purge-v0' "$package_dir/install.sh" \
|
||||
|| fail "acceptance: the installer must never run destructive cleanup on its own"
|
||||
|
||||
@@ -852,11 +950,14 @@ $piped_matcher"
|
||||
|| fail "acceptance: прогон тестов оркестратора не фиксируется результатом"
|
||||
grep -q 'ADMIN_TESTS_PASSED' tools/build/lib/package.sh \
|
||||
|| fail "acceptance: прогон тестов админки не фиксируется результатом"
|
||||
grep -q 'FRONTEND_TESTS_PASSED' tools/build/lib/package.sh \
|
||||
|| fail "acceptance: прогон контрактных тестов панели не фиксируется результатом"
|
||||
"$BUN_BIN" -e '
|
||||
const source = require("node:fs").readFileSync("tools/build/lib/package.sh", "utf8");
|
||||
for (const [fn, flag] of [
|
||||
["run_orchestrator_tests()", "ORCHESTRATOR_TESTS_PASSED=\"true\""],
|
||||
["run_admin_tests()", "ADMIN_TESTS_PASSED=\"true\""]
|
||||
["run_admin_tests()", "ADMIN_TESTS_PASSED=\"true\""],
|
||||
["run_frontend_tests()", "FRONTEND_TESTS_PASSED=\"true\""]
|
||||
]) {
|
||||
const start = source.indexOf(fn);
|
||||
if (start < 0) throw new Error("не найдена функция " + fn);
|
||||
@@ -1785,14 +1886,29 @@ run_legacy_account_acceptance() {
|
||||
done
|
||||
|
||||
log_step "Acceptance: v1 docs carry no previous-generation vocabulary"
|
||||
# docs/14 — единственное место, где эти имена обозначают реальные объекты
|
||||
# для удаления. В обычных docs их быть не должно.
|
||||
# Руководство по очистке предыдущего поколения — единственное место, где эти
|
||||
# имена обозначают реальные объекты для удаления. В остальных docs их быть не
|
||||
# должно.
|
||||
#
|
||||
# Обход РЕКУРСИВНЫЙ. Раньше здесь стоял плоский `docs/*.md`, и это работало,
|
||||
# пока документы лежали одной кучей в корне docs. После разнесения по
|
||||
# тематическим каталогам такой шаблон совпадал бы ровно с одним файлом —
|
||||
# docs/README.md, — то есть проверка отчитывалась бы зелёным, не заглянув
|
||||
# почти никуда.
|
||||
local doc
|
||||
for doc in docs/*.md; do
|
||||
while IFS= read -r doc; do
|
||||
[ -n "$doc" ] || continue
|
||||
case "$doc" in
|
||||
docs/14-legacy-cleanup.md) continue ;;
|
||||
docs/operations/14-legacy-cleanup.md) continue ;;
|
||||
esac
|
||||
! grep -q 'H_UI_' "$doc" \
|
||||
|| fail "acceptance: previous-generation config keys leaked into $doc"
|
||||
done
|
||||
done <<EOF
|
||||
$(find docs -type f -name '*.md' | sort)
|
||||
EOF
|
||||
|
||||
# Каталог обязан быть непустым: `find` по опечатке в пути вернул бы пустой
|
||||
# список, а пустой список для проверки «такого здесь нет» означает успех.
|
||||
[ "$(find docs -type f -name '*.md' | wc -l)" -ge 10 ] \
|
||||
|| fail "acceptance: обход документации нашёл подозрительно мало файлов"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user