6d1686b2be
Второй разбор того же слоя, уже по состоянию после 162759c. Тема: границы между
частями access-control. Прошлый проход починил одну операцию отзыва доступа и
оставил остальные; правило доступа при этом продолжало существовать в двух
экземплярах. Проведены три границы: состояние пира -> решение о доступе,
сохранённое изменение -> живая сессия, планировщик -> принадлежащая ему работа.
Правило доступа. Оно было записано двумя разными SQL-условиями: одним в выборке
Hysteria2Auth, другим в выборке cron. Второе не является отрицанием первого, и
расхождение приходилось ровно на границы — quota=0, usage=quota, now=expiresAt,
now=bannedUntil: авторизация отказывала, cron сессию не рвал. Условие cron
требовало СТРОГОГО превышения квоты, а счётчики растут порциями по ответу
Traffic Stats API, поэтому точное равенство — обычный исход очередного сбора.
Пир с исчерпанной квотой не пускался заново, но его живая сессия не разрывалась
никогда. Политика вынесена в peerAccessDenied; авторизация ищет пира только по
secret_digest, cron применяет ту же функцию. quota=-1 — единственный безлимит,
quota=0 — ноль байтов, bannedUntil=now — блокировка уже закончилась. Строка без
решающего поля трактуется как повреждённая и ведёт к отказу.
Операции, оставлявшие живую сессию. DeletePeer состоял из одного dao.DeletePeer:
строка исчезала вместе с auth_id, то есть вместе с единственным, чем эту сессию
можно было завершить, — состояние становилось невосстановимым. Разрыв при
изменении выполнялся только при disabled=1, поэтому мимо проходили смена
секрета, урезание квоты ниже израсходованного, перенос срока в прошлое и
снижение maxDevices. Импорт переписывает auth_id, секрет, квоту, срок и disabled
целиком и не трогал сессий вовсе. Все операции идут теперь через один
reconcileLiveSessions, а он — через disconnectAuthIDs, единственный вход к /kick:
он принимает готовые идентификаторы, дедуплицирует их, разбивает на части и не
обращается к базе. Импорт собирает старые auth_id ВНУТРИ транзакции (после
commit их в базе уже нет) и рвёт ПОСЛЕ commit (до него клиент успел бы
переподключиться к ещё не изменённому пиру). Правило асимметрично намеренно:
ограничение применяется немедленно, послабление — нет.
Цикл учёта. CronHandleAccount запускала горутину, которая запускала ещё две, —
для планировщика джоба заканчивалась почти мгновенно, поэтому StopCron не ждал
настоящей работы: releaseResource закрывал SQLite, а горутины продолжали в неё
писать. Параллельность обеих половин означала ещё и то, что enforcement читал
счётчики до записи снятой дельты. Джоба стала синхронной, под одним мьютексом на
весь цикл, порядок строгий. Закрыты три nil-разыменования — trafficSecretConfig,
item.AuthId и item.Id, — каждое из которых роняло процесс целиком вместе с
обработчиком machine-auth. Гейт Hysteria2IsRunning убран: util.Exec не отличает
«служба неактивна» от «спросить не удалось», и сломанный systemctl при живой
Hysteria молча отключал и учёт, и enforcement. Потеря дельты при отказе SQLite
больше не молчит: чтение /traffic?clear=1 деструктивно, и каждая потеря
считается. Checkpoint accounting в 1.0.0 намеренно не вводится — квота здесь
операционный предел доступа, а не учёт с финансово значимым каждым байтом.
Лимит устройств. Между чтением /online и ответом allow место ничем не
удерживалось: при online=max-1 два одновременных запроса получали разрешение
оба. Мьютекс вокруг /online этого не чинит — ответив allow, админка не создаёт
подключение, и следующий запрос продолжает видеть прежнее число. Появился
process-local учёт выданных, но ещё не проявившихся разрешений: решение по сумме
«подключено плюс зарезервировано», рост online снимает соответствующее их число,
протухшие снимаются по внутреннему TTL. Сеть опрашивается вне блокировки.
Гейты. Проверка «авторизация не возвращает успех из ветки ошибки» была записана
регуляркой err != nil \{[\s\S]*?return \*peer\.Id, а ленивый [\s\S]*? свободно
пересекает границы блоков: она даёт совпадение на коде из HEAD, то есть гейт
нельзя было удовлетворить, не сломав продукт. Тело ветки теперь выделяется по
балансу фигурных скобок, и логика проверена в обе стороны. go test -race стал
обязательным шагом сборки: состояние трекера разрешений и мьютекс цикла учёта
принадлежат процессу, и их корректность не наблюдаема ни в go test, ни в go vet;
пропуск при недоступном компиляторе не предусмотрен.
Панель. importPeerApi не объявлял skipErrorToast, а handleImport не имел ни try,
ни catch: после появления частичного результата отказ уходил бы необработанным
отклонением промиса, список не обновлялся бы при уже изменённой базе, а общий
перехватчик показал бы предупреждение красной ошибкой. Формулировка
peer_disconnect_failed во всех трёх местах сделана operation-neutral: через этот
код отчитываются восемь операций, а для удалённого пира прежняя фраза «новые
подключения пира запрещены» просто бессмысленна.
506 lines
25 KiB
TypeScript
506 lines
25 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
||
import fs from "node:fs";
|
||
import path from "node:path";
|
||
|
||
import { ERR_CODE, API_CODE } from "../../apps/frontend/src/utils/api-error";
|
||
import {
|
||
FLAMY_NAME,
|
||
FLAMY_URL,
|
||
} from "../../apps/frontend/src/constants/branding";
|
||
import ru from "../../apps/frontend/src/lang/package/ru";
|
||
import en from "../../apps/frontend/src/lang/package/en";
|
||
|
||
/**
|
||
* Контракты панели, которые нельзя проверить ни типами, ни сборкой bundle.
|
||
*
|
||
* Все три жили на честном слове: словари локализации расходились молча,
|
||
* коды ошибок существовали в двух местах без связи между ними, а адрес
|
||
* атрибуции ничто не удерживало от расползания по шаблонам.
|
||
*/
|
||
|
||
const REPO_ROOT = path.resolve(import.meta.dir, "..", "..");
|
||
const FRONTEND_SRC = path.join(REPO_ROOT, "apps", "frontend", "src");
|
||
|
||
function sourceFiles(dir: string): string[] {
|
||
const out: string[] = [];
|
||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||
const full = path.join(dir, entry.name);
|
||
if (entry.isDirectory()) {
|
||
out.push(...sourceFiles(full));
|
||
continue;
|
||
}
|
||
if (/\.(ts|vue|scss|js)$/i.test(entry.name)) {
|
||
out.push(full);
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** Коды причин, объявленные сервером в constant.ErrCode*. */
|
||
function serverErrorCodes(): Set<string> {
|
||
const source = fs.readFileSync(
|
||
path.join(REPO_ROOT, "apps", "model", "constant", "error.go"),
|
||
"utf8"
|
||
);
|
||
const codes = new Set<string>();
|
||
for (const match of source.matchAll(
|
||
/ErrCode[A-Za-z]+\s+string\s*=\s*"([^"]+)"/g
|
||
)) {
|
||
codes.add(match[1]);
|
||
}
|
||
return codes;
|
||
}
|
||
|
||
function leafKeys(value: unknown, prefix = ""): string[] {
|
||
if (typeof value !== "object" || value === null) {
|
||
return [prefix];
|
||
}
|
||
return Object.entries(value as Record<string, unknown>).flatMap(([key, v]) =>
|
||
leafKeys(v, prefix ? `${prefix}.${key}` : key)
|
||
);
|
||
}
|
||
|
||
describe("локализация", () => {
|
||
// Ключ, забытый в одном словаре, не ломает ни типы, ни сборку: vue-i18n
|
||
// молча отдаёт сам ключ, и оператор видит `error.code.min_length` вместо
|
||
// фразы. Единственное место, где это может быть замечено заранее, — здесь.
|
||
test("наборы ключей ru и en совпадают", () => {
|
||
const ruKeys = new Set(leafKeys(ru));
|
||
const enKeys = new Set(leafKeys(en));
|
||
|
||
expect([...ruKeys].filter((key) => !enKeys.has(key)).sort()).toEqual([]);
|
||
expect([...enKeys].filter((key) => !ruKeys.has(key)).sort()).toEqual([]);
|
||
});
|
||
|
||
// Панель выбирает фразу по коду ответа. Код без фразы доезжает до оператора
|
||
// серверным сообщением — это работает, но на языке сервера, а не панели.
|
||
test("у каждого известного кода ошибки есть фраза в обоих словарях", () => {
|
||
const missing: string[] = [];
|
||
for (const code of Object.values(ERR_CODE)) {
|
||
for (const [locale, dictionary] of [
|
||
["ru", ru],
|
||
["en", en],
|
||
] as const) {
|
||
const messages = (dictionary as any).error?.code ?? {};
|
||
if (typeof messages[code] !== "string") {
|
||
missing.push(`${locale}: error.code.${code}`);
|
||
}
|
||
}
|
||
}
|
||
expect(missing).toEqual([]);
|
||
});
|
||
|
||
test("коды ответа совпадают с серверными константами", () => {
|
||
const codeSource = fs.readFileSync(
|
||
path.join(REPO_ROOT, "apps", "model", "constant", "code.go"),
|
||
"utf8"
|
||
);
|
||
const declared = new Map<string, number>();
|
||
for (const match of codeSource.matchAll(
|
||
/(Code[A-Za-z]+)\s+int\s*=\s*(\d+)/g
|
||
)) {
|
||
declared.set(match[1], Number(match[2]));
|
||
}
|
||
|
||
expect(declared.get("CodeSuccess")).toBe(API_CODE.success);
|
||
expect(declared.get("CodeSysError")).toBe(API_CODE.systemError);
|
||
expect(declared.get("CodeInvalidError")).toBe(API_CODE.validationFailed);
|
||
expect(declared.get("CodeUnauthorizedError")).toBe(API_CODE.unauthorized);
|
||
expect(declared.get("CodeForbiddenError")).toBe(API_CODE.forbidden);
|
||
});
|
||
|
||
// Коды причин объявлены на сервере; панель обязана знать их под теми же
|
||
// именами. Расхождение здесь тихо отключает локализацию для целого класса
|
||
// отказов.
|
||
//
|
||
// Проверяются ОБА направления. Одного мало: направление «панель → сервер»
|
||
// ловит выдуманный код, но не ловит серверный код, о котором панель не
|
||
// знает, — а именно так добавляется новое правило. Ровно это здесь и
|
||
// случилось: коды min_length/max_length появились на сервере после того, как
|
||
// карта кодов панели была написана, и проверка в одну сторону молчала.
|
||
test("коды причин совпадают с серверными константами", () => {
|
||
const serverCodes = serverErrorCodes();
|
||
|
||
expect(serverCodes.size).toBeGreaterThan(0);
|
||
|
||
const unknownToServer = Object.values(ERR_CODE).filter(
|
||
(code) => !serverCodes.has(code)
|
||
);
|
||
expect(unknownToServer).toEqual([]);
|
||
|
||
const known = new Set<string>(Object.values(ERR_CODE));
|
||
expect([...serverCodes].filter((code) => !known.has(code)).sort()).toEqual(
|
||
[]
|
||
);
|
||
});
|
||
|
||
// Серверный код без фразы доезжает до оператора сообщением сервера — это
|
||
// работает, но на языке сервера, а не панели.
|
||
test("у каждого серверного кода есть фраза в обоих словарях", () => {
|
||
const missing: string[] = [];
|
||
for (const code of serverErrorCodes()) {
|
||
for (const [locale, dictionary] of [
|
||
["ru", ru],
|
||
["en", en],
|
||
] as const) {
|
||
const messages = (dictionary as any).error?.code ?? {};
|
||
if (typeof messages[code] !== "string") {
|
||
missing.push(`${locale}: error.code.${code}`);
|
||
}
|
||
}
|
||
}
|
||
expect(missing).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe("атрибуция Flamy", () => {
|
||
test("адрес объявлен один раз и ведёт на flamy.studio", () => {
|
||
expect(FLAMY_URL).toBe("https://flamy.studio");
|
||
expect(FLAMY_NAME).toBe("Flamy");
|
||
|
||
const carriers = sourceFiles(FRONTEND_SRC).filter((file) =>
|
||
fs.readFileSync(file, "utf8").includes("flamy.studio")
|
||
);
|
||
expect(carriers.map((file) => path.relative(REPO_ROOT, file))).toEqual([
|
||
path.join("apps", "frontend", "src", "constants", "branding.ts"),
|
||
]);
|
||
});
|
||
|
||
// Оператор HY2XS не должен иметь возможности переназначить, куда ведёт
|
||
// подпись разработчика. Проверяются все каналы, через которые значение
|
||
// могло бы стать настраиваемым.
|
||
test("адрес не является операторской настройкой", () => {
|
||
const operatorSurfaces = [
|
||
"package/config/hy2xs.env",
|
||
"package/templates/env/post-install.env.tpl",
|
||
"apps/model/constant/config.go",
|
||
];
|
||
for (const relative of operatorSurfaces) {
|
||
const source = fs.readFileSync(path.join(REPO_ROOT, relative), "utf8");
|
||
expect(source.toLowerCase()).not.toContain("flamy");
|
||
}
|
||
});
|
||
|
||
test("футер отрисован в боковом меню и учтён в его высоте", () => {
|
||
const sidebar = fs.readFileSync(
|
||
path.join(FRONTEND_SRC, "layout", "components", "Sidebar", "index.vue"),
|
||
"utf8"
|
||
);
|
||
expect(sidebar).toContain("<Footer");
|
||
|
||
// Высота области прокрутки обязана вычитать высоту футера, иначе пункты
|
||
// меню наезжают на подпись при длинном списке.
|
||
const styles = fs.readFileSync(
|
||
path.join(FRONTEND_SRC, "styles", "sidebar.scss"),
|
||
"utf8"
|
||
);
|
||
expect(styles).toContain("$sidebarFooterHeight");
|
||
|
||
const variables = fs.readFileSync(
|
||
path.join(FRONTEND_SRC, "styles", "variables.scss"),
|
||
"utf8"
|
||
);
|
||
expect(variables).toMatch(/\$sidebarFooterHeight:\s*\d+px/);
|
||
});
|
||
|
||
test("внешняя ссылка открывается безопасно", () => {
|
||
const footer = fs.readFileSync(
|
||
path.join(FRONTEND_SRC, "layout", "components", "Sidebar", "Footer.vue"),
|
||
"utf8"
|
||
);
|
||
const anchors = [...footer.matchAll(/<a\b[\s\S]*?>/g)].map((m) => m[0]);
|
||
expect(anchors.length).toBeGreaterThan(0);
|
||
for (const anchor of anchors) {
|
||
expect(anchor).toContain('target="_blank"');
|
||
expect(anchor).toContain('rel="noopener noreferrer"');
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("секрет пира", () => {
|
||
// Прямой запрет из требований: генерация секрета принадлежит серверу.
|
||
// Панель, подставляющая значение в пустое поле, выполняла бы обещание
|
||
// «сгенерируем автоматически» ровно для одной двери из четырёх.
|
||
test("панель не генерирует секрет сама", () => {
|
||
const form = fs.readFileSync(
|
||
path.join(FRONTEND_SRC, "views", "peer", "list", "index.vue"),
|
||
"utf8"
|
||
);
|
||
expect(form).not.toMatch(/crypto\.getRandomValues/);
|
||
expect(form).not.toMatch(/Math\.random/);
|
||
// Подстановка значения в пустой секрет перед отправкой — тот же обход
|
||
// другими средствами.
|
||
expect(form).not.toMatch(/secret\s*=\s*dataForm\.secret\s*\|\|\s*["'`]/);
|
||
});
|
||
|
||
test("пустой секрет уезжает на сервер как есть", () => {
|
||
const form = fs.readFileSync(
|
||
path.join(FRONTEND_SRC, "views", "peer", "list", "index.vue"),
|
||
"utf8"
|
||
);
|
||
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");
|
||
});
|
||
|
||
// Импорт — такая же операция с частичным исходом, как и действия строки:
|
||
// партия применена целиком, а старые сессии обновлённых пиров завершить не
|
||
// удалось.
|
||
//
|
||
// Раньше у него не было ни try, ни catch: отказ уходил необработанным
|
||
// отклонением промиса, а обновление списка до выполнения не доходило —
|
||
// оператор видел прежние данные при уже изменённой базе.
|
||
test("импорт разбирает свой исход и обновляет список при любом", () => {
|
||
const source = codeOf(peerList());
|
||
|
||
const start = source.indexOf("async function handleImport");
|
||
expect(start).toBeGreaterThan(-1);
|
||
const rest = source.slice(start + 1);
|
||
const end = rest.indexOf("\nasync function ");
|
||
const body = end < 0 ? rest : rest.slice(0, end);
|
||
|
||
expect(body).toContain("reportPeerActionError");
|
||
// Обновление списка стоит ПОСЛЕ разбора исхода, а не внутри успешной
|
||
// ветки: при частичном результате состояние в базе уже изменилось.
|
||
const handled = body.indexOf("reportPeerActionError");
|
||
const refreshed = body.indexOf("handleQuery()");
|
||
expect(refreshed).toBeGreaterThan(handled);
|
||
});
|
||
|
||
// Срок временной блокировки называется оператору: раньше `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",
|
||
// Импорт применяет партию одной транзакцией, а старые сессии
|
||
// обновлённых пиров завершает после её фиксации. Второй шаг умеет не
|
||
// удаться отдельно от первого, и тогда общий перехватчик показал бы
|
||
// частичный результат красной ошибкой — сообщив оператору обратное
|
||
// тому, что произошло.
|
||
"importPeerApi",
|
||
];
|
||
|
||
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");
|
||
}
|
||
});
|
||
});
|