fix(admin): достроить вторые половины отзыва доступа, лимита и журнала

Разбор кода на c0a43ae со сверкой с официальной документацией Hysteria 2.
Общая тема: операции, у которых была только одна из двух необходимых половин.

Отзыв доступа. Запись disabled=1 видит лишь выборка в Hysteria2Auth, то есть
закрывает БУДУЩИЕ обращения к HTTP-auth; установленная QUIC-сессия живёт своей
жизнью и сама не разрывается. После «Отключить» пир пользовался доступом сколько
угодно долго, а панель показывала его отключённым. Появился DisconnectPeers —
только официальный Traffic Stats /kick, без записи в базу; прежний Hysteria2Kick
вместе с разрывом проставлял banned_until и потому для отключения не годился.
Порядок «запись, затем разрыв» обратному не подлежит и доказан снимком базы в
момент прихода /kick. Неудача разрыва не откатывает disabled и сообщается кодом
peer_disconnect_failed: обычная ошибка означала бы для оператора вывод, прямо
противоположный истине. KickPeer переведён на тот же примитив — он писал
banned_until дважды и мог ответить чистым отказом уже в применённом состоянии.

Ограничение устройств. Отказ /online обрабатывался возвратом успеха
авторизации, то есть недоступность 127.0.0.1 превращала объявленный лимит в
безлимит. Вторая половина дыры была тише: Hysteria2Online отдавал пустую карту
БЕЗ ошибки, когда systemd отвечал «служба неактивна», — а этот ответ не
отличается от «спросить systemctl не удалось». Пути разделены: терпимый для
отображения, строгий для решения о доступе. Hysteria2IsRunning убран с путей
принятия решений совсем.

Журнал. entry.Info() вызывался без аргумента, и logrus писал "msg":"" для
каждого запроса — пустой столбец на экране был точным отражением файла. Ветка
«файла ещё нет» отвечала голым массивом вместо {records,total}, поэтому на
свежей установке страница системных логов не работала вовсе. Битая строка
вызывала vo.Fail И continue: клиент получал два JSON-документа подряд.

Панель. Общий LogViewer и utils/download.ts (копий скачивания было четыре, две
ставили запрос вне try и глушили причину); меню на command с быстрым
включением/отключением; popper-style у подсказки; kick с подтверждением и
названным сроком; отмена подтверждений перестала быть ошибкой. Отдельно:
skipErrorToast гасил и транспортный отказ, при том что страницы писали
«перехватчик уже показал» и молчали, — обрыв связи не показывал ничего.

Закреплено go-тестами против настоящего HTTP, контрактными тестами панели и
двумя гейтами приёмки. Ручная часть — в
docs/acceptance/2026-09-01-v1.0.0-rc2-preflight-findings.md.
This commit is contained in:
2026-09-01 17:17:17 +05:00
parent c0a43ae915
commit 162759c599
37 changed files with 3072 additions and 343 deletions
+232
View File
@@ -241,3 +241,235 @@ describe("секрет пира", () => {
expect(form).toContain("await savePeerApi(dataForm)");
});
});
function readSource(...segments: string[]): string {
return fs.readFileSync(path.join(FRONTEND_SRC, ...segments), "utf8");
}
/**
* Содержимое файла без комментариев.
*
* Проверять положено КОД, а не упоминания. Комментарий, объясняющий, почему
* конструкции здесь больше нет, обязан называть её по имени, иначе он
* бесполезен, — и проверка «такого вызова в файле нет» начинает падать ровно
* на той документации, которая описывает выполненную ею же работу.
* Отбрасываются строчные `//` и строки тела блочных комментариев `*`.
*/
function codeOf(source: string): string {
return source
.split("\n")
.filter((line) => !/^\s*(\/\/|\/?\*)/.test(line))
.join("\n");
}
const LOG_PAGES = [
["views", "log", "system", "index.vue"],
["views", "log", "hysteria", "index.vue"],
];
describe("страницы журнала", () => {
// Обе страницы были побайтово одинаковы и несли одни и те же три дефекта в
// двух экземплярах: колонки без ширин, сетевой запрос ВНЕ try и пустой
// catch. Починка в двух местах разошлась бы снова.
test("обе страницы построены на общем LogViewer", () => {
for (const page of LOG_PAGES) {
const source = readSource(...page);
expect(source).toContain("@/components/LogViewer/index.vue");
// Собственной таблицы у страницы быть не должно: ширины колонок живут в
// одном месте, иначе они снова разъедутся.
expect(source).not.toContain("<el-table-column");
}
});
test("ширины колонок журнала заданы явно", () => {
const viewer = readSource("components", "LogViewer", "index.vue");
// Без width/min-width Element Plus делит ширину практически поровну, и
// сообщение — единственное содержимое журнала — получает треть строки.
const columns = [...viewer.matchAll(/<el-table-column[\s\S]*?(?:\/>|>)/g)].map(
(match) => match[0]
);
expect(columns.length).toBeGreaterThanOrEqual(3);
for (const column of columns) {
expect(column).toMatch(/\b(width|min-width)=/);
}
// Колонка сообщения растягивается за счёт остатка, служебные — фиксированы.
expect(viewer).toMatch(/prop="msg"[\s\S]*?min-width="\d+"/);
expect(viewer).toMatch(/prop="level"[\s\S]*?width="\d+"/);
expect(viewer).toMatch(/prop="time"[\s\S]*?width="\d+"/);
// Диагностический JSON Hysteria переносится, а не обрезается многоточием.
expect(viewer).toContain("pre-wrap");
});
// Регрессия LOG-03. Было:
//
// const response = await exportLogApi(...); // запрос ВНЕ try
// try { ... } catch (e) { /* empty */ }
//
// Отказ самого запроса этим catch не ловился вовсе, а всё внутри глушилось
// молча: оператор не получал ни файла, ни причины.
test("выгрузка журнала выполняется внутри try и сообщает об отказе", () => {
const viewer = readSource("components", "LogViewer", "index.vue");
const exportCall = viewer.indexOf("await exportLogApi");
const tryBlock = viewer.indexOf("try {");
expect(exportCall).toBeGreaterThan(-1);
expect(tryBlock).toBeGreaterThan(-1);
expect(tryBlock).toBeLessThan(exportCall);
// Пустой catch — это и есть проглатывание причины.
expect(viewer).not.toMatch(/catch[^)]*\)?\s*\{\s*(\/\*[\s\S]*?\*\/)?\s*\}/);
expect(viewer).toContain("log.exportFailed");
});
// Сборка ссылки на скачивание жила в трёх копиях и все три успели
// разойтись. Копия обязана быть одна.
test("скачивание файла собрано в одном месте", () => {
const owners: string[] = [];
for (const file of sourceFiles(FRONTEND_SRC)) {
const source = fs.readFileSync(file, "utf8");
if (source.includes("createObjectURL")) {
owners.push(path.relative(FRONTEND_SRC, file).replace(/\\/g, "/"));
}
}
expect(owners).toEqual(["utils/download.ts"]);
});
});
describe("действия над пиром", () => {
const peerList = () => readSource("views", "peer", "list", "index.vue");
// Регрессия UX-06: пункта быстрого включения/отключения в меню не было
// вовсе, хотя это самое частое действие оператора.
test("меню строки содержит быстрое включение/отключение", () => {
const source = peerList();
expect(source).toContain('command="toggle-disabled"');
expect(source).toContain("peer.enablePeer");
expect(source).toContain("peer.disablePeer");
});
// el-dropdown объявляет command штатным способом описывать меню действий:
// команда приходит в одно место, и добавить пункт, забыв его подключить,
// становится невозможно.
test("меню строки построено на command, а не на click по пунктам", () => {
const source = peerList();
expect(source).toContain("@command=");
const items = [...source.matchAll(/<el-dropdown-item[\s\S]*?>/g)].map(
(match) => match[0]
);
expect(items.length).toBeGreaterThanOrEqual(6);
for (const item of items) {
expect(item).toContain("command=");
expect(item).not.toContain("@click");
}
});
// Регрессия UX-07: подсказка без ограничения ширины растягивалась почти на
// весь экран одной строкой. Ограничение ставится штатным popper-style, а не
// глобальным CSS.
test("широкая подсказка ограничена popper-style", () => {
const source = peerList();
expect(source).toContain(":popper-style=");
expect(source).toMatch(/maxWidth:\s*"\d+px"/);
});
// Регрессия UX-09: ElMessageBox отклоняет промис при нажатии «Отмена», и
// `await ElMessageBox.confirm(...)` без разбора отказа оставлял
// необработанное отклонение промиса на каждую отмену.
test("отмена подтверждения не считается ошибкой", () => {
// Считается КОД, а не упоминания: комментарий, объясняющий, почему прямой
// вызов здесь больше не стоит, обязан называть его по имени — иначе он
// бесполезен. Это тот же урок, что записан в code_without_comments в
// acceptance.sh, и он повторился прямо на этой проверке.
const confirms = [...codeOf(peerList()).matchAll(/ElMessageBox\.confirm\(/g)];
// Единственный вызов — внутри confirmAction, который переводит отмену в
// обычное `false`.
expect(confirms.length).toBe(1);
expect(peerList()).toContain("async function confirmAction");
expect(peerList()).toContain("await confirmAction(");
});
// Частичный результат отзыва доступа — не отказ операции: запрет новых
// подключений уже записан. Панель обязана отличать его по коду и обновлять
// строку, а не показывать «не сработало».
test("частичный результат отзыва доступа разбирается по коду", () => {
const source = peerList();
expect(source).toContain("ERR_CODE.peerDisconnectFailed");
expect(source).toContain("ElMessage.warning");
});
// Срок временной блокировки называется оператору: раньше `Date.now() + час`
// был зашит в обработчик и не сообщался ни до, ни после.
test("временная блокировка подтверждается и называет срок", () => {
const source = peerList();
expect(source).toContain("peer.kickConfirm");
expect(source).toContain("KICK_DURATION_MS");
});
// Об одном результате оператор узнаёт ОДИН раз. Действия строки сообщают
// исход сами, поэтому общий перехватчик обязан молчать: иначе рядом с
// предупреждением о частичном отзыве доступа встанет ошибка от перехватчика —
// два противоречащих друг другу сообщения об одном событии.
test("действия строки не получают второго тоста от перехватчика", () => {
const api = codeOf(readSource("api", "peer", "index.ts"));
const selfReporting = [
"deletePeerApi",
"resetPeerTrafficApi",
"releaseKickPeerApi",
"kickPeerApi",
"updatePeerApi",
"savePeerApi",
];
for (const name of selfReporting) {
const start = api.indexOf(`export function ${name}`);
expect(start).toBeGreaterThan(-1);
const rest = api.slice(start + 1);
const end = rest.indexOf("\nexport function ");
const body = end < 0 ? rest : rest.slice(0, end);
expect(body).toContain("skipErrorToast: true");
}
});
// Флаг относится только к отказу API. У транспортного отказа конверта нет,
// страница о нём сказать ничего не может и молчит — а пока флаг гасил и эту
// ветку, обрыв соединения при сохранении, удалении или отзыве доступа не
// показывал оператору ничего вовсе.
test("транспортный отказ показывается всегда", () => {
const request = codeOf(readSource("utils", "request.ts"));
const handler = request.slice(request.indexOf("(error: AxiosError)"));
expect(handler).toContain("ElMessage.error(message)");
expect(handler).not.toContain("skipErrorToast");
});
});
describe("подсказки формы пира", () => {
// Подсказка обещала более узкий набор символов, чем принимает сервер: она
// осталась от правила, действовавшего до EX-03. Оператор не имел причин
// пробовать разрешённые символы.
test("подсказка имени описывает действующий набор символов", () => {
// Набор символов объявлен на сервере ровно один раз; подсказка обязана
// описывать именно его, а не набор из предыдущего поколения правила.
const rule = fs.readFileSync(
path.join(REPO_ROOT, "apps", "service", "peer_import.go"),
"utf8"
);
const declared = rule.match(/PeerNameCharset\s*=\s*`([^`]+)`/);
expect(declared).not.toBeNull();
// «a-z A-Z 0-9 !@#$%^&*()_+-=» -> «!@#$%^&*()_+-=»
const punctuation = declared![1].split(/\s+/).at(-1)!;
expect(punctuation).toContain("!@#");
for (const dictionary of [ru, en] as const) {
const hint = (dictionary as any).peer?.form?.nameHint ?? "";
expect(hint).toContain(punctuation);
expect(hint).toContain("32");
}
});
});