Files
founder c0a43ae915 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 после
разнесения по каталогам совпадал бы ровно с одним файлом.
2026-09-01 07:27:15 +05:00

163 lines
6.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, test } from "bun:test";
import {
assertCleanHost,
detectLegacyMarkers,
legacyMarkersFor,
renderLegacyFailure,
type HostProbe
} from "../src/steps/cleanHost";
import {
ORCHESTRATOR_INSTALL_DIR,
ORCHESTRATOR_SYMLINK_PATH,
RUNTIME_PACKAGE_DIR
} from "../src/config/profile";
import { baselineConfig } from "./fixtures";
/**
* Проба, которая считает «существующими» ровно переданный набор целей.
* Это позволяет проверить контракт чистого хоста без файловой системы.
*/
function probeWith(present: readonly string[]): HostProbe {
const set = new Set(present);
return {
async fileExists(path) {
return set.has(path);
},
async dirExists(path) {
return set.has(path);
},
async unitExists(unit) {
return set.has(unit);
}
};
}
const config = baselineConfig();
describe("clean-host контракт", () => {
test("чистый хост проходит", async () => {
await expect(assertCleanHost(config, probeWith([]))).resolves.toBeUndefined();
});
test("каждый маркер по отдельности останавливает установку", async () => {
const markers = legacyMarkersFor(config);
expect(markers.length).toBeGreaterThan(10);
for (const marker of markers) {
const probe = probeWith([marker.target]);
await expect(assertCleanHost(config, probe)).rejects.toThrow(
/предыдущая или посторонняя установка/
);
}
});
test("список покрывает состояние, юниты, бинарник и наследие 0.x", () => {
const targets = legacyMarkersFor(config).map((marker) => marker.target);
for (const expected of [
"/etc/hysteria/post-install.env",
"/etc/hy2xs/hy2xs.env",
"/var/lib/hy2xs/install-state.json",
"/etc/hy2xs/bootstrap-admin.secret",
"/usr/local/lib/hy2xs/package",
"/usr/local/lib/hy2xs",
"/usr/local/bin/hy2xs-orchestrator",
"/etc/hysteria/config.yaml",
"/usr/local/bin/hysteria",
"/var/lib/hysteria",
"/etc/nftables.d/hy2xs.nft",
"hy2xs-admin.service",
"hysteria-server.service",
"h-ui.service",
"/usr/local/h-ui",
config.installDir,
config.dataDir,
config.logDir
]) {
expect(targets).toContain(expected);
}
});
/**
* purge-v0.sh и clean-host обязаны описывать одну и ту же границу.
*
* Регрессия: purge удалял /var/lib/hysteria, /var/log/hy2xs,
* /usr/local/lib/hy2xs и /usr/local/bin/hy2xs-orchestrator, а clean-host их
* не проверял. Сервер, где остался только старый ACME-state Hysteria,
* проходил проверку и получал свежую установку поверх чужого runtime.
*/
test("всё, что удаляет purge-v0.sh, проверяется clean-host контрактом", () => {
const purgedPaths = [
"/etc/hy2xs",
"/etc/hysteria",
"/var/lib/hy2xs",
"/var/lib/hy2xs-admin",
"/var/lib/hysteria",
"/var/log/hy2xs",
"/opt/hy2xs-admin",
"/usr/local/lib/hy2xs",
"/usr/local/h-ui",
"/usr/local/bin/hy2xs-orchestrator",
"/usr/local/bin/hysteria",
"/etc/nftables.d/hy2xs.nft"
];
const targets = legacyMarkersFor(config).map((marker) => marker.target);
for (const purged of purgedPaths) {
const covered = targets.some((target) => target === purged || target.startsWith(`${purged}/`));
expect(covered, `purge удаляет ${purged}, но clean-host его не проверяет`).toBe(true);
}
});
/**
* Регрессия архитектуры установки.
*
* Пока PHASE 1 начиналась в install.sh, у списка маркеров была вторая,
* «мягкая» версия: пути, которые shell успевал создать между фазами,
* исключались из проверки. Это была не оптимизация, а компенсация того, что
* мутировали двое.
*
* Теперь bootstrap выполняет сам оркестратор — уже ПОСЛЕ clean-host, — и
* послаблений быть не должно: любой из этих путей на входе означает чужую
* установку, при любом вызове.
*/
test("bootstrap-пути остаются маркерами без всяких исключений", async () => {
for (const target of [ORCHESTRATOR_INSTALL_DIR, RUNTIME_PACKAGE_DIR, ORCHESTRATOR_SYMLINK_PATH]) {
await expect(assertCleanHost(config, probeWith([target]))).rejects.toThrow(
/предыдущая или посторонняя установка/
);
}
});
test("маркеры bootstrap-путей берутся из профиля, а не из копий строк", () => {
const targets = legacyMarkersFor(config).map((marker) => marker.target);
expect(targets).toContain(ORCHESTRATOR_INSTALL_DIR);
expect(targets).toContain(RUNTIME_PACKAGE_DIR);
expect(targets).toContain(ORCHESTRATOR_SYMLINK_PATH);
});
test("пути из конфигурации попадают в список, а не только дефолтные", () => {
const custom = baselineConfig({
HY2XS_INSTALL_DIR: "/srv/hy2xs-app",
HY2XS_DATA_DIR: "/srv/hy2xs-data"
});
const targets = legacyMarkersFor(custom).map((marker) => marker.target);
expect(targets).toContain("/srv/hy2xs-app");
expect(targets).toContain("/srv/hy2xs-data");
});
test("сообщение перечисляет все найденные маркеры и говорит, что хост не изменён", async () => {
const found = await detectLegacyMarkers(
legacyMarkersFor(config),
probeWith(["/etc/hy2xs/hy2xs.env", "hy2xs-admin.service"])
);
expect(found).toHaveLength(2);
const message = renderLegacyFailure(found);
expect(message).toContain("/etc/hy2xs/hy2xs.env");
expect(message).toContain("hy2xs-admin.service");
expect(message).toContain("Ни один файл на сервере не изменён");
expect(message).toContain("docs/operations/14-legacy-cleanup.md");
});
});