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:
@@ -737,6 +737,8 @@ run_clean_install_acceptance() {
|
||||
run_single_owner_acceptance "$package_dir"
|
||||
run_secret_channel_acceptance
|
||||
run_atomic_import_acceptance
|
||||
run_access_revocation_acceptance
|
||||
run_observability_acceptance
|
||||
run_legacy_account_acceptance
|
||||
run_scheduler_lifecycle_acceptance
|
||||
run_dead_config_acceptance
|
||||
@@ -1525,6 +1527,166 @@ run_atomic_import_acceptance() {
|
||||
done
|
||||
}
|
||||
|
||||
# Отзыв доступа: обе половины официального контракта Hysteria и fail-closed
|
||||
# ограничение устройств.
|
||||
run_access_revocation_acceptance() {
|
||||
log_step "Acceptance: disabling a peer also terminates its live session"
|
||||
# Запись `disabled=1` закрывает только БУДУЩИЕ обращения к HTTP-auth:
|
||||
# установленная QUIC-сессия сама по себе не рвётся. Официальная документация
|
||||
# описывает `/kick` и блокировку в auth backend как пару — по отдельности не
|
||||
# работает ни одна половина.
|
||||
code_has apps/service/hysteria2_api.go -F -- 'func DisconnectPeers' \
|
||||
|| fail "acceptance: the session disconnect primitive is missing"
|
||||
code_has apps/service/peer.go -F -- 'func disconnectAfterRevoke' \
|
||||
|| fail "acceptance: revoking access must go through a single disconnect path"
|
||||
code_has apps/service/peer.go -F -- 'DisconnectPeers(' \
|
||||
|| fail "acceptance: revoking access never reaches the Traffic Stats /kick"
|
||||
|
||||
log_step "Acceptance: session disconnect does not write peer state"
|
||||
# Прежний Hysteria2Kick вместе с разрывом проставлял `banned_until`, поэтому
|
||||
# им нельзя было воспользоваться для отключения пира: операция записала бы
|
||||
# заодно временную блокировку — другой механизм с другим сроком жизни.
|
||||
"$BUN_BIN" -e '
|
||||
const source = require("node:fs").readFileSync("apps/service/hysteria2_api.go", "utf8");
|
||||
const start = source.indexOf("func DisconnectPeers");
|
||||
if (start < 0) throw new Error("DisconnectPeers is missing");
|
||||
const rest = source.slice(start + 1);
|
||||
const end = rest.indexOf("\nfunc ");
|
||||
const body = end < 0 ? rest : rest.slice(0, end);
|
||||
for (const forbidden of ["banned_until", "disabled", "dao.UpdatePeer("]) {
|
||||
if (body.includes(forbidden)) {
|
||||
throw new Error("DisconnectPeers writes peer state: " + forbidden);
|
||||
}
|
||||
}
|
||||
' || fail "acceptance: the disconnect primitive must not write peer state"
|
||||
|
||||
log_step "Acceptance: a partial revocation is reported by code, not by prose"
|
||||
# Долговременная запись к этому моменту уже применена и НЕ откатывается:
|
||||
# достигнутое безопасное состояние нельзя отменять из-за неудачи второго
|
||||
# шага. Без отдельного кода оператор прочитал бы обычную ошибку как
|
||||
# «не сработало, состояние прежнее».
|
||||
code_has apps/model/constant/error.go -F -- 'ErrCodePeerDisconnectFailed' \
|
||||
|| fail "acceptance: the partial-revocation reason code is missing"
|
||||
code_has apps/service/peer_errors.go -F -- 'func PeerDisconnectError' \
|
||||
|| fail "acceptance: the partial-revocation error is missing from the service layer"
|
||||
code_has apps/controller/peer.go -F -- 'failService(err, c)' \
|
||||
|| fail "acceptance: peer actions must map domain reasons to response codes"
|
||||
code_has apps/frontend/src/utils/api-error.ts -F -- 'peer_disconnect_failed' \
|
||||
|| fail "acceptance: the panel does not know the partial-revocation code"
|
||||
local locale
|
||||
for locale in ru en; do
|
||||
code_has "apps/frontend/src/lang/package/${locale}.ts" -F -- 'peer_disconnect_failed' \
|
||||
|| fail "acceptance: the partial-revocation phrase is missing from the ${locale} locale"
|
||||
done
|
||||
|
||||
log_step "Acceptance: the device limit is fail-closed"
|
||||
# Отказ Traffic Stats API превращал объявленный «Лимит устройств: 3» в
|
||||
# безлимит, и узнать об этом можно было только по строке WARN в журнале.
|
||||
# Форма старой дыры: возврат успеха авторизации из ветки ошибки /online.
|
||||
"$BUN_BIN" -e '
|
||||
const source = require("node:fs").readFileSync("apps/service/hysteria2_api.go", "utf8");
|
||||
const start = source.indexOf("func Hysteria2Auth");
|
||||
if (start < 0) throw new Error("Hysteria2Auth is missing");
|
||||
const rest = source.slice(start + 1);
|
||||
const end = rest.indexOf("\nfunc ");
|
||||
const body = end < 0 ? rest : rest.slice(0, end);
|
||||
const code = body
|
||||
.split("\n")
|
||||
.filter((line) => !/^\s*\/\//.test(line))
|
||||
.join("\n");
|
||||
if (!code.includes("hysteria2Online()")) {
|
||||
throw new Error("auth no longer asks the Traffic Stats API directly");
|
||||
}
|
||||
// Терпимый путь отдаёт ПУСТУЮ КАРТУ БЕЗ ОШИБКИ, когда systemd отвечает
|
||||
// «служба неактивна», — а этот ответ не отличается от «спросить не
|
||||
// удалось». На пути принятия решения о доступе ему не место.
|
||||
if (/[^a-z0-9_]Hysteria2Online\(\)/.test(code)) {
|
||||
throw new Error("auth takes the display-tolerant online path");
|
||||
}
|
||||
const failOpen = /err != nil \{[\s\S]*?return \*peer\.Id/;
|
||||
if (failOpen.test(code)) {
|
||||
throw new Error("auth still returns success from the /online failure branch");
|
||||
}
|
||||
' || fail "acceptance: the device limit must be fail-closed"
|
||||
|
||||
code_has apps/service/hysteria2.go -F -- 'var hysteria2IsRunning' \
|
||||
|| fail "acceptance: the systemd state seam is missing, so fail-closed is untestable"
|
||||
|
||||
log_step "Acceptance: the public endpoint error names no transport"
|
||||
# Публичный endpoint Hysteria — UDP/QUIC, и «TCP port» закладывал в
|
||||
# сообщение заведомо ложную семантику.
|
||||
! code_has apps/service/hysteria2_api.go -F -- 'must be a valid TCP port' \
|
||||
|| fail "acceptance: the public port error must not claim a TCP transport"
|
||||
}
|
||||
|
||||
# Наблюдаемость: журнал админки и страницы, которые его показывают.
|
||||
run_observability_acceptance() {
|
||||
log_step "Acceptance: request log entries carry a message"
|
||||
# `entry.Info()` без аргумента писал `"msg":""` для КАЖДОГО запроса, и
|
||||
# страница системных логов показывала пустой столбец сообщений.
|
||||
code_has apps/middleware/log.go -F -- 'func RequestLogMessage' \
|
||||
|| fail "acceptance: the request log message builder is missing"
|
||||
! code_has apps/middleware/log.go -E -- 'entry\.(Info|Warn|Error)\(\)' \
|
||||
|| fail "acceptance: request log entries are still written without a message"
|
||||
|
||||
log_step "Acceptance: the request log still hides query values"
|
||||
# Человекочитаемое сообщение не является лазейкой: Hysteria обращается к
|
||||
# машинному endpoint'у с действующим токеном в query при каждом подключении
|
||||
# пира, а журнал уезжает оператору через ExportLog.
|
||||
! code_has apps/middleware/log.go -F -- 'RequestURI' \
|
||||
|| fail "acceptance: the request log must never carry the query string"
|
||||
! code_has apps/middleware/log.go -F -- 'RawQuery' \
|
||||
|| fail "acceptance: the request log must never carry query values"
|
||||
|
||||
log_step "Acceptance: the log page keeps its response shape on every branch"
|
||||
# Ветка «файла ещё нет» отвечала голым массивом, панель читала
|
||||
# `data.records` и получала undefined: на свежепоставленном хосте страница
|
||||
# системных логов не работала вовсе.
|
||||
! code_has apps/controller/log.go -F -- 'vo.Success(logSystemVos, c)' \
|
||||
|| fail "acceptance: the system log page must always answer with {records,total}"
|
||||
# Отказ внутри цикла писал ответ и продолжал перебор, а в конце безусловно
|
||||
# выполнялся vo.Success: клиент получал два JSON-документа подряд.
|
||||
"$BUN_BIN" -e '
|
||||
const source = require("node:fs").readFileSync("apps/controller/log.go", "utf8");
|
||||
const start = source.indexOf("func LogSystem");
|
||||
if (start < 0) throw new Error("LogSystem is missing");
|
||||
const rest = source.slice(start + 1);
|
||||
const end = rest.indexOf("\nfunc ");
|
||||
const body = end < 0 ? rest : rest.slice(0, end);
|
||||
const code = body
|
||||
.split("\n")
|
||||
.filter((line) => !/^\s*\/\//.test(line))
|
||||
.join("\n");
|
||||
if (/vo\.Fail\([\s\S]*?\n\s*continue\b/.test(code)) {
|
||||
throw new Error("a broken log line still writes a response and keeps looping");
|
||||
}
|
||||
' || fail "acceptance: a broken log line must be skipped, not answered"
|
||||
|
||||
log_step "Acceptance: both log pages share one table"
|
||||
# Страницы были побайтово одинаковы и несли одни и те же дефекты в двух
|
||||
# экземплярах: колонки без ширин и выгрузку с запросом вне try.
|
||||
[ -f apps/frontend/src/components/LogViewer/index.vue ] \
|
||||
|| fail "acceptance: the shared log table component is missing"
|
||||
local page
|
||||
for page in system hysteria; do
|
||||
code_has "apps/frontend/src/views/log/${page}/index.vue" -F -- 'LogViewer' \
|
||||
|| fail "acceptance: the ${page} log page does not use the shared table"
|
||||
! code_has "apps/frontend/src/views/log/${page}/index.vue" -F -- '<el-table-column' \
|
||||
|| fail "acceptance: the ${page} log page carries its own table again"
|
||||
done
|
||||
code_has apps/frontend/src/components/LogViewer/index.vue -E -- 'min-width="[0-9]+"' \
|
||||
|| fail "acceptance: log table columns must declare explicit widths"
|
||||
|
||||
log_step "Acceptance: file download is built in exactly one place"
|
||||
# Последовательность Blob/createObjectURL/ссылка/клик жила в четырёх копиях,
|
||||
# и все успели разойтись: две из них ставили сетевой запрос ВНЕ try и
|
||||
# глушили причину пустым catch.
|
||||
local download_hits
|
||||
download_hits="$(code_mentions_in 'createObjectURL' apps/frontend/src)"
|
||||
[ "$download_hits" = "apps/frontend/src/utils/download.ts" ] \
|
||||
|| fail "acceptance: file download must live in utils/download.ts alone. Found: $download_hits"
|
||||
}
|
||||
|
||||
# Границы транзакции операции: снятие guard'а и взаимное исключение операций.
|
||||
run_transaction_boundary_acceptance() {
|
||||
log_step "Acceptance: disarming the firewall guard is proven, not announced"
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user