1152 lines
58 KiB
TypeScript
1152 lines
58 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 {
|
||
ADMIN_PASSWORD_MAX_BYTES,
|
||
ADMIN_PASSWORD_MAX_LENGTH,
|
||
ADMIN_PASSWORD_MIN_LENGTH,
|
||
ADMIN_USERNAME_CHARSET,
|
||
ADMIN_USERNAME_MAX_LENGTH,
|
||
ADMIN_USERNAME_MIN_LENGTH,
|
||
ADMIN_USERNAME_PATTERN,
|
||
isValidAdminPassword,
|
||
} from "../../apps/frontend/src/constants/credentials";
|
||
import {
|
||
PEER_NAME_CHARSET,
|
||
PEER_NAME_MAX_LENGTH,
|
||
PEER_NAME_MIN_LENGTH,
|
||
PEER_NAME_PATTERN,
|
||
PEER_NAME_PUNCTUATION,
|
||
} from "../../apps/frontend/src/constants/peer";
|
||
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");
|
||
}
|
||
|
||
/**
|
||
* То же для SFC: у шаблона свои комментарии.
|
||
*
|
||
* `codeOf` отбрасывает только комментарии JS/TS, а в `<template>` они
|
||
* записываются как `<!-- … -->`. Комментарий, объясняющий, почему на этом
|
||
* месте больше нет выдуманного `:9999`, обязан называть его по имени — и
|
||
* проверка «такого значения в файле нет» падала ровно на той документации,
|
||
* которая описывает выполненную ею же работу. Это тот же урок, что уже записан
|
||
* в `codeOf`, просто повторившийся этажом выше.
|
||
*/
|
||
function vueCodeOf(source: string): string {
|
||
return codeOf(source.replace(/<!--[\s\S]*?-->/g, ""));
|
||
}
|
||
|
||
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("страница конфигурации Hysteria", () => {
|
||
const configPage = () => readSource("views", "hysteria", "list", "index.vue");
|
||
const configTypes = () => readSource("api", "config", "types.ts");
|
||
|
||
// Страница показывает ФАЙЛ, а не представление о файле.
|
||
//
|
||
// Прежняя версия строила форму merge'ем ответа сервера поверх полного
|
||
// объекта значений по умолчанию, поэтому отсутствующая секция `trafficStats`
|
||
// показывалась как `:9999`, а `speedTest: false` считался ненастроенным.
|
||
// Диагностический экран скрывал ровно тот дрейф, ради которого его
|
||
// открывают.
|
||
test("синтетических значений по умолчанию не осталось", () => {
|
||
const source = vueCodeOf(configPage());
|
||
|
||
expect(source).not.toContain(":9999");
|
||
expect(source).not.toContain("normalizeHysteriaViewModel");
|
||
expect(source).not.toContain("defaultHysteria2ServerConfigView");
|
||
|
||
// Сам модуль view-модели удалён вместе с дефолтами: пока он существует,
|
||
// его легко подключить обратно.
|
||
expect(
|
||
fs.existsSync(
|
||
path.join(FRONTEND_SRC, "api", "config", "hysteriaViewModel.ts")
|
||
)
|
||
).toBe(false);
|
||
});
|
||
|
||
// Конфигом владеет оркестратор, маршрутов записи в API нет. Поля ввода на
|
||
// такой странице обещают оператору действие, которого не существует.
|
||
test("страница не изображает редактируемую форму", () => {
|
||
const source = vueCodeOf(configPage());
|
||
|
||
for (const editable of [
|
||
"<el-input",
|
||
"<el-switch",
|
||
"<el-select",
|
||
"v-model",
|
||
]) {
|
||
expect(source).not.toContain(editable);
|
||
}
|
||
});
|
||
|
||
// Расхождение — главный ответ этого экрана: секция вне профиля означает, что
|
||
// файл правил не оркестратор.
|
||
test("расхождение конфигурации показывается отдельно", () => {
|
||
const source = configPage();
|
||
|
||
expect(source).toContain("profile.drift");
|
||
expect(source).toContain("hysteria.driftTitle");
|
||
});
|
||
|
||
// Отсутствие Traffic Stats API выключает лимит устройств, учёт трафика и
|
||
// принудительное отключение разом. Раньше на этом месте показывался
|
||
// выдуманный адрес.
|
||
test("отсутствие trafficStats названо отказом, а не значением", () => {
|
||
const source = configPage();
|
||
|
||
expect(source).toContain("hysteria.trafficStatsMissing");
|
||
expect(source).toContain("trafficStatsListenState");
|
||
});
|
||
|
||
// Достижимость и соответствие профилю — РАЗНЫЕ вопросы.
|
||
//
|
||
// Первая версия отвечала только на первый: `0.0.0.0` считался нормой, потому
|
||
// что через wildcard админка достучится. Но wildcard — не loopback:
|
||
// внутренний control plane при нём опубликован на всех интерфейсах, и при
|
||
// `HY2XS_FIREWALL_MODE=external|off` его не прикрывает ничто. Оркестратор
|
||
// такой конфигурации не создаёт, значит она появилась правкой руками — и
|
||
// диагностический экран обязан это назвать.
|
||
test("адрес Traffic Stats API различает достижимость и профиль", () => {
|
||
const source = vueCodeOf(configPage());
|
||
|
||
// Канон профиля ровно один, и он назван литералом.
|
||
expect(source).toContain('"127.0.0.1"');
|
||
// Пустой хост — тот же wildcard, что и 0.0.0.0: в Go `:36712` означает все
|
||
// интерфейсы, а не loopback.
|
||
expect(source).toMatch(/host === "" \|\| host === "0\.0\.0\.0"/);
|
||
|
||
for (const state of ["canonical", "wildcard", "unreachable"]) {
|
||
expect(source).toContain(state);
|
||
}
|
||
|
||
// Прочие адреса loopback — это НЕ «слегка не тот адрес», а
|
||
// недостижимость: слушатель на конкретном адресе принимает соединения
|
||
// только на него, поэтому bind 127.0.0.5 + dial 127.0.0.1 даёт connection
|
||
// refused. Отдельного мягкого состояния для них быть не должно.
|
||
expect(source).not.toContain("nonCanonicalLoopback");
|
||
expect(source).not.toMatch(/127\\\.\\d/);
|
||
|
||
// Две разные новости — две разные фразы, и ни одна не потеряна в словарях.
|
||
for (const dictionary of [ru, en] as const) {
|
||
const hysteria = (dictionary as any).hysteria ?? {};
|
||
expect(typeof hysteria.trafficStatsUnreachable).toBe("string");
|
||
expect(typeof hysteria.trafficStatsWildcard).toBe("string");
|
||
}
|
||
});
|
||
|
||
// Read-only экран не имеет права быть щедрее санитизированной выгрузки того
|
||
// же конфига. Пароль обфускации, токены ACME DNS и учётные данные
|
||
// outbound-прокси в ответе больше не приезжают.
|
||
test("тип ответа не несёт секретов", () => {
|
||
const types = codeOf(configTypes());
|
||
|
||
expect(types).toContain("passwordSet");
|
||
expect(types).toContain("secretSet");
|
||
expect(types).toContain("dnsConfigKeys");
|
||
|
||
expect(types).not.toMatch(/\bpassword\s*[?]?:/);
|
||
expect(types).not.toMatch(/\bsecret\s*[?]?:/);
|
||
// Секции, которых production-профиль не описывает, в модели ответа не
|
||
// объявляются вовсе: они попадают в `drift` списком имён.
|
||
for (const section of [
|
||
"outbounds",
|
||
"masquerade",
|
||
"resolver",
|
||
"sniff",
|
||
"acl",
|
||
"mimic",
|
||
"realm",
|
||
]) {
|
||
expect(types).not.toContain(`${section}:`);
|
||
}
|
||
});
|
||
|
||
// Имя ACME DNS-провайдера показывается как ФАКТИЧЕСКОЕ значение профиля.
|
||
//
|
||
// Прежняя страница несла жёсткий список провайдеров, то есть второй экземпляр
|
||
// upstream-реестра, который мог от него отстать: `namedotcom` пришлось
|
||
// выпиливать вручную после удаления в Hysteria 2.11.0. Панель больше ничего
|
||
// не предлагает — конфигом владеет оркестратор, — поэтому реестра в UI нет и
|
||
// быть не должно: новый upstream-провайдер отображается без правок панели.
|
||
test("ACME DNS provider показывается как фактическое значение профиля", () => {
|
||
const source = vueCodeOf(configPage());
|
||
const types = codeOf(configTypes());
|
||
|
||
expect(source).toContain("profile.acme.dnsProvider");
|
||
expect(source).toContain("text(profile.acme.dnsProvider)");
|
||
expect(types).toContain("dnsProvider: string | null");
|
||
|
||
// Реестр провайдеров в панели не возвращается ни под каким видом.
|
||
for (const provider of [
|
||
"cloudflare",
|
||
"duckdns",
|
||
"gandi",
|
||
"godaddy",
|
||
"namecheap",
|
||
"njalla",
|
||
"porkbun",
|
||
"vultr",
|
||
"namedotcom",
|
||
]) {
|
||
expect(source).not.toContain(`"${provider}"`);
|
||
}
|
||
|
||
// Значения параметров DNS — ключ от DNS-зоны: read-only экран получает
|
||
// только их имена.
|
||
expect(types).toContain("dnsConfigKeys: string[]");
|
||
expect(types).not.toMatch(/dnsConfig:\s*(Record|\{)/);
|
||
});
|
||
|
||
// Компоненты генерического редактора удалены вместе со страницей: пока они
|
||
// существуют, «универсальный редактор Hysteria» отрастает заново.
|
||
test("компоненты генерического редактора удалены", () => {
|
||
for (const component of ["MapAdd", "ImputMultiple", "UnitSelect"]) {
|
||
expect(
|
||
fs.existsSync(path.join(FRONTEND_SRC, "components", component))
|
||
).toBe(false);
|
||
}
|
||
expect(
|
||
fs.existsSync(
|
||
path.join(FRONTEND_SRC, "views", "hysteria", "list", "components")
|
||
)
|
||
).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe("правдивая диагностика", () => {
|
||
// «Состояние неизвестно» и «служба остановлена» — разные ответы. Пока
|
||
// состояние было булевым, сломанный systemctl при живой Hysteria показывал
|
||
// критическую плашку «служба остановлена».
|
||
test("дашборд показывает три состояния службы", () => {
|
||
for (const dictionary of [ru, en] as const) {
|
||
const states = (dictionary as any).dashboard?.serviceState ?? {};
|
||
expect(typeof states.active).toBe("string");
|
||
expect(typeof states.inactive).toBe("string");
|
||
expect(typeof states.unknown).toBe("string");
|
||
}
|
||
|
||
const dashboard = readSource("views", "dashboard", "index.vue");
|
||
expect(dashboard).toContain("summary.hysteria.serviceState");
|
||
// Доступность API — независимый факт, а не следствие состояния службы.
|
||
expect(dashboard).toContain("summary.hysteria.apiReachable");
|
||
});
|
||
|
||
// Отказ Traffic Stats API больше не изображается строками «офлайн»: это
|
||
// ответ, уводящий оператора искать проблему у пользователей.
|
||
test("список пиров отличает «офлайн» от «неизвестно»", () => {
|
||
const peerList = readSource("views", "peer", "list", "index.vue");
|
||
|
||
expect(peerList).toContain("onlineState");
|
||
expect(peerList).toContain("peer.onlineUnknown");
|
||
expect(peerList).toContain("peer.onlineUnavailable");
|
||
});
|
||
});
|
||
|
||
describe("контракт учётных данных администратора", () => {
|
||
const goContract = () =>
|
||
fs.readFileSync(
|
||
path.join(REPO_ROOT, "apps", "credential", "admin.go"),
|
||
"utf8"
|
||
);
|
||
|
||
function goConst(name: string): string {
|
||
const source = goContract();
|
||
const numeric = source.match(new RegExp(`\\b${name}\\s*=\\s*(\\d+)`));
|
||
if (numeric) {
|
||
return numeric[1];
|
||
}
|
||
const backticked = source.match(
|
||
new RegExp(`\\b${name}\\s*=\\s*\`([^\`]*)\``)
|
||
);
|
||
if (backticked) {
|
||
return backticked[1];
|
||
}
|
||
throw new Error(`константа ${name} не найдена в apps/credential/admin.go`);
|
||
}
|
||
|
||
const loginForm = () => readSource("views", "login", "index.vue");
|
||
const changePasswordForm = () =>
|
||
readSource("views", "admin", "change-password", "index.vue");
|
||
|
||
// Панель не имеет права быть строже сервера: отвергнутое ею значение сервер
|
||
// принял бы, и оператор остаётся снаружи с паролем, который на самом деле
|
||
// подходит.
|
||
test("границы и набор символов совпадают с Go-контрактом", () => {
|
||
expect(String(ADMIN_USERNAME_MIN_LENGTH)).toBe(
|
||
goConst("AdminUsernameMinLength")
|
||
);
|
||
expect(String(ADMIN_USERNAME_MAX_LENGTH)).toBe(
|
||
goConst("AdminUsernameMaxLength")
|
||
);
|
||
expect(String(ADMIN_PASSWORD_MIN_LENGTH)).toBe(
|
||
goConst("AdminPasswordMinLength")
|
||
);
|
||
expect(String(ADMIN_PASSWORD_MAX_LENGTH)).toBe(
|
||
goConst("AdminPasswordMaxLength")
|
||
);
|
||
// Предел bcrypt — отдельная константа и отдельная сверка. Пока панель знала
|
||
// только границу в символах, она предлагала оператору назначить пароль из
|
||
// 64 кириллических букв (128 байт), сервер его принимал всеми проверками, а
|
||
// хеширование отказывало — и оператор получал системную ошибку на штатной
|
||
// смене пароля.
|
||
expect(String(ADMIN_PASSWORD_MAX_BYTES)).toBe(
|
||
goConst("AdminPasswordMaxBytes")
|
||
);
|
||
expect(ADMIN_USERNAME_CHARSET).toBe(goConst("AdminUsernameCharset"));
|
||
|
||
const declared = goConst("adminUsernameCharacterClass");
|
||
const mirrored = ADMIN_USERNAME_PATTERN.source
|
||
.replace(/^\^\[/, "")
|
||
.replace(
|
||
new RegExp(
|
||
`\\]\\{${ADMIN_USERNAME_MIN_LENGTH},${ADMIN_USERNAME_MAX_LENGTH}\\}\\$$`
|
||
),
|
||
""
|
||
);
|
||
expect(mirrored).toBe(declared);
|
||
});
|
||
|
||
// Регрессия RC2-UI-01. Дефис внутри класса символов не был экранирован, из-за
|
||
// чего `+-=` образовывал ДИАПАЗОН и впускал `, - . / 0-9 : ; < =`. Набор
|
||
// совпадал с серверным только по совпадению — оба несли одну и ту же
|
||
// опечатку, — и любая попытка «навести порядок» развела бы их обратно.
|
||
test("класс символов записан явно, а не диапазоном по опечатке", () => {
|
||
expect(ADMIN_USERNAME_PATTERN.source).toContain("\\-");
|
||
// Считается КОД: комментарий, объясняющий, почему прежней записи здесь
|
||
// больше нет, обязан называть её по имени — иначе он бесполезен. Это тот же
|
||
// урок, что записан в vueCodeOf этажом выше, и он повторился прямо здесь.
|
||
for (const form of [loginForm(), changePasswordForm()]) {
|
||
expect(vueCodeOf(form)).not.toContain("_+-=");
|
||
}
|
||
});
|
||
|
||
// Правило объявлено ОДИН раз. Пока каждая форма несла своё, они разошлись и с
|
||
// сервером, и друг с другом.
|
||
test("обе формы берут правило из общего модуля, а не заводят своё", () => {
|
||
for (const form of [loginForm(), changePasswordForm()]) {
|
||
expect(form).toContain("@/constants/credentials");
|
||
// Собственных регекспов и собственных чисел в формах быть не должно.
|
||
expect(vueCodeOf(form)).not.toMatch(/pattern\s*[:=]\s*\//);
|
||
expect(vueCodeOf(form)).not.toMatch(/\{\s*6\s*,\s*\d+\s*\}/);
|
||
}
|
||
});
|
||
|
||
// Регрессия RC2-UI-02: форма входа ограничивала пароль 32 символами при
|
||
// серверном пределе в 64, а форма смены пароля назначала до 64. Пароль,
|
||
// назначенный штатной операцией, становился паролем, с которым невозможно
|
||
// войти.
|
||
test("правило пароля на обеих формах — одно и то же", () => {
|
||
for (const form of [loginForm(), changePasswordForm()]) {
|
||
const code = vueCodeOf(form);
|
||
expect(code).toContain("adminPasswordFormRule(");
|
||
expect(code).toContain("credentials.passwordFormat");
|
||
}
|
||
});
|
||
|
||
// Регрессия RC2-UI-04. Element Plus делегирует правила формы библиотеке
|
||
// `async-validator`, а она сравнивает `min`/`max` строки с
|
||
// `String.prototype.length`, то есть считает единицы UTF-16. Сервер считает
|
||
// code points. Пароль из трёх эмодзи имел `value.length === 6`, проходил
|
||
// минимум формы и получал отказ сервера, который панель не могла объяснить.
|
||
//
|
||
// Встроенных границ на пароле поэтому быть не должно вовсе: длина
|
||
// принадлежит правилу, а не соседям по объекту.
|
||
test("на пароле нет встроенных min/max от async-validator", async () => {
|
||
const { adminPasswordFormRule } =
|
||
await import("../../apps/frontend/src/constants/credentials");
|
||
|
||
// Правило одно на обе формы, и границ как СВОЙСТВ правила у него нет: длина
|
||
// живёт внутри валидатора, где её можно посчитать правильно.
|
||
const rule = adminPasswordFormRule("format") as Record<string, unknown>;
|
||
expect(typeof rule.validator).toBe("function");
|
||
expect("min" in rule).toBe(false);
|
||
expect("max" in rule).toBe(false);
|
||
|
||
// И ни одна форма не заводит собственных границ рядом с ним. Числа в
|
||
// сообщении не считаются: фраза обязана НАЗЫВАТЬ требование, поэтому они
|
||
// подставляются в t() и вырезаются здесь перед проверкой.
|
||
for (const form of [loginForm(), changePasswordForm()]) {
|
||
const withoutMessages = vueCodeOf(form).replace(
|
||
/t\("credentials\.passwordFormat",[\s\S]*?\n\s*\}\)/g,
|
||
't("credentials.passwordFormat")'
|
||
);
|
||
expect(withoutMessages).not.toContain("min: ADMIN_PASSWORD_MIN_LENGTH");
|
||
expect(withoutMessages).not.toContain("max: ADMIN_PASSWORD_MAX_LENGTH");
|
||
}
|
||
});
|
||
|
||
// Правило панели обязано отвечать так же, как Go-контракт, на тех значениях,
|
||
// где расходятся способы счёта длины.
|
||
test("правило пароля считает code points и байты, а не UTF-16", () => {
|
||
// Проверка самой таблицы: если «я» перестанет быть двухбайтовой, случаи
|
||
// ниже проверяли бы не то, что написано.
|
||
expect(new TextEncoder().encode("я".repeat(36)).length).toBe(
|
||
ADMIN_PASSWORD_MAX_BYTES
|
||
);
|
||
expect(new TextEncoder().encode("😀".repeat(18)).length).toBe(
|
||
ADMIN_PASSWORD_MAX_BYTES
|
||
);
|
||
|
||
for (const accepted of [
|
||
"a".repeat(ADMIN_PASSWORD_MIN_LENGTH),
|
||
"a".repeat(ADMIN_PASSWORD_MAX_LENGTH),
|
||
"я".repeat(36),
|
||
"😀".repeat(18),
|
||
"abcde ",
|
||
" ".repeat(ADMIN_PASSWORD_MIN_LENGTH),
|
||
]) {
|
||
expect(isValidAdminPassword(accepted)).toBe(true);
|
||
}
|
||
|
||
for (const rejected of [
|
||
"a".repeat(ADMIN_PASSWORD_MIN_LENGTH - 1),
|
||
"a".repeat(ADMIN_PASSWORD_MAX_LENGTH + 1),
|
||
"я".repeat(37),
|
||
"😀".repeat(19),
|
||
"я".repeat(ADMIN_PASSWORD_MAX_LENGTH),
|
||
"abcde\n",
|
||
"abcde\t",
|
||
// Cc целиком, а не только C0 и DEL: документация обещала «без
|
||
// управляющих символов», а проверка знала лишь про часть из них.
|
||
`abcde${String.fromCodePoint(0x7f)}`,
|
||
`abcde${String.fromCodePoint(0x85)}`,
|
||
`abcde${String.fromCodePoint(0x9f)}`,
|
||
// Домен systemd: шесть символов, ни одного управляющего — и юнит после
|
||
// такого пароля не стартует, потому что EnvironmentFile не загрузится.
|
||
`abcde${String.fromCodePoint(0xfdd0)}`,
|
||
`abcde${String.fromCodePoint(0xffff)}`,
|
||
`abcde${String.fromCodePoint(0x1ffff)}`,
|
||
// U+FEFF запрещён публичным транспортным контрактом EnvironmentFile.
|
||
`abcde${String.fromCodePoint(0xfeff)}`,
|
||
// Одиночный суррогат: без проверки TextEncoder молча подменил бы его на
|
||
// U+FFFD, то есть на сервер уехал бы другой пароль, а не отказ.
|
||
`abcde${String.fromCharCode(0xd800)}`,
|
||
]) {
|
||
expect(isValidAdminPassword(rejected)).toBe(false);
|
||
}
|
||
|
||
// Соседи запрещённых диапазонов обязаны проходить: правило описывает ровно
|
||
// множество systemd, а не окрестность подозрительных значений.
|
||
for (const accepted of [0xfdcf, 0xfdf0, 0xfffd, 0x10fffd]) {
|
||
expect(
|
||
isValidAdminPassword(`abcde${String.fromCodePoint(accepted)}`)
|
||
).toBe(true);
|
||
}
|
||
|
||
// Именно тот случай, ради которого встроенные min/max убраны: три эмодзи —
|
||
// это 3 code points и 6 единиц UTF-16.
|
||
expect("😀😀😀".length).toBe(6);
|
||
expect(isValidAdminPassword("😀😀😀")).toBe(false);
|
||
});
|
||
|
||
// Пустое значение — забота правила `required`, а не правила формата: иначе
|
||
// оператор, отправивший пустую форму, читает про границы длины вместо «поле
|
||
// обязательно». Сервер ведёт себя так же (DTO приводит пустой пароль к «не
|
||
// задано» до проверки правил).
|
||
test("пустой пароль остаётся на правиле required", async () => {
|
||
const { adminPasswordFormRule } =
|
||
await import("../../apps/frontend/src/constants/credentials");
|
||
const rule = adminPasswordFormRule("format");
|
||
|
||
const errors: (Error | undefined)[] = [];
|
||
rule.validator(null, "", (error?: Error) => errors.push(error));
|
||
rule.validator(null, "abc", (error?: Error) => errors.push(error));
|
||
|
||
expect(errors[0]).toBeUndefined();
|
||
expect(errors[1]).toBeInstanceOf(Error);
|
||
});
|
||
|
||
// Набор символов пароля сервером не проверяется НИГДЕ, поэтому его проверка в
|
||
// панели умеет только отвергнуть пароль, который сервер принял бы.
|
||
test("на пароль не навешен набор символов", () => {
|
||
for (const form of [loginForm(), changePasswordForm()]) {
|
||
const code = vueCodeOf(form);
|
||
expect(code).not.toContain(
|
||
'ADMIN_USERNAME_PATTERN,\n message: t("credentials.passwordFormat"'
|
||
);
|
||
expect(code).not.toMatch(/passwordPattern/);
|
||
}
|
||
// Правило логина — единственное место, где вообще упоминается набор.
|
||
expect(vueCodeOf(changePasswordForm())).not.toContain(
|
||
"ADMIN_USERNAME_PATTERN"
|
||
);
|
||
});
|
||
|
||
// Требование НАЗЫВАЕТСЯ, а не сообщается фактом нарушения: набор символов
|
||
// логина приходит из hy2xs.env, и посмотреть его в панели больше негде.
|
||
test("фразы называют границы и набор, и есть в обоих словарях", () => {
|
||
for (const dictionary of [ru, en] as const) {
|
||
const credentials = (dictionary as any).credentials ?? {};
|
||
expect(typeof credentials.usernameFormat).toBe("string");
|
||
expect(typeof credentials.passwordFormat).toBe("string");
|
||
|
||
for (const placeholder of ["{min}", "{max}", "{charset}"]) {
|
||
expect(credentials.usernameFormat).toContain(placeholder);
|
||
}
|
||
// Границ у пароля ДВЕ, и они в разных единицах: пароль из 40 эмодзи
|
||
// укладывается в 64 символа и не укладывается в 72 байта. Фраза «не
|
||
// длиннее {max} символов» отправила бы оператора сокращать пароль,
|
||
// отвергнутый не за это.
|
||
for (const placeholder of ["{min}", "{max}", "{maxBytes}"]) {
|
||
expect(credentials.passwordFormat).toContain(placeholder);
|
||
}
|
||
|
||
// Причина отказа от сервера несёт те же params и обязана их использовать.
|
||
const reason = (dictionary as any).error?.code?.credential_format ?? "";
|
||
for (const placeholder of ["{min}", "{max}", "{charset}"]) {
|
||
expect(reason).toContain(placeholder);
|
||
}
|
||
const passwordReason =
|
||
(dictionary as any).error?.code?.admin_password_format ?? "";
|
||
for (const placeholder of ["{min}", "{max}", "{maxBytes}"]) {
|
||
expect(passwordReason).toContain(placeholder);
|
||
}
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("индикация ошибки на форме входа", () => {
|
||
const loginForm = () => readSource("views", "login", "index.vue");
|
||
|
||
// Регрессия RC2-UI-03, видимая на скриншоте формы.
|
||
//
|
||
// Element Plus рисует состояние отказа на `el-input__wrapper` селектором
|
||
//
|
||
// .el-form-item.is-error .el-form-item__content .el-input__wrapper
|
||
//
|
||
// то есть из ЧЕТЫРЁХ классов. Форма входа рисует видимую рамку поля на
|
||
// `el-form-item` — потому что в поле лежат ещё иконка и переключатель
|
||
// видимости пароля, — а тень враппера гасила селектором из трёх классов и
|
||
// проигрывала по специфичности. Красная рамка ложилась вокруг одного лишь
|
||
// поля ввода: у логина начиналась после иконки, у пароля обрывалась перед
|
||
// «глазом».
|
||
test("отказ подсвечивает поле целиком, а не один только ввод", () => {
|
||
const styles = loginForm();
|
||
|
||
// Индикация переехала на элемент, который оператор и видит полем.
|
||
expect(styles).toMatch(
|
||
/&\.is-error\s*\{[\s\S]*?border-color:\s*var\(--el-color-danger\)/
|
||
);
|
||
|
||
// Штатная тень враппера гасится селектором, который повторяет чужой и
|
||
// добавляет атрибут scoped-стиля, — то есть выигрывает по специфичности.
|
||
expect(styles).toContain(
|
||
":deep(.el-form-item__content .el-input__wrapper)"
|
||
);
|
||
|
||
// Победа специфичностью, а не силой: `!important` перекрыл бы и те
|
||
// состояния, о которых правило ничего не знает.
|
||
expect(styles).not.toContain("box-shadow: none !important");
|
||
});
|
||
|
||
// Сообщение об отказе позиционируется абсолютно от `top: 100%`, то есть
|
||
// висит ВНЕ рамки. При стандартных 18px оно прижималось к границе снизу и к
|
||
// следующему полю сверху и выглядело принадлежащим не тому полю.
|
||
test("сообщению об отказе оставлено место под полем", () => {
|
||
const styles = loginForm();
|
||
expect(styles).toContain(":deep(.el-form-item__error)");
|
||
expect(styles).toMatch(/margin-bottom:\s*(1[9]|[2-9]\d)px/);
|
||
});
|
||
});
|
||
|
||
describe("подсказки формы пира", () => {
|
||
const peerContract = () =>
|
||
fs.readFileSync(
|
||
path.join(REPO_ROOT, "apps", "service", "peer_import.go"),
|
||
"utf8"
|
||
);
|
||
|
||
function peerGoConst(name: string): string {
|
||
const source = peerContract();
|
||
const numeric = source.match(new RegExp(`\\b${name}\\s*=\\s*(\\d+)`));
|
||
if (numeric) {
|
||
return numeric[1];
|
||
}
|
||
const backticked = source.match(
|
||
new RegExp(`\\b${name}\\s*=\\s*\`([^\`]*)\``)
|
||
);
|
||
if (backticked) {
|
||
return backticked[1];
|
||
}
|
||
throw new Error(
|
||
`константа ${name} не найдена в apps/service/peer_import.go`
|
||
);
|
||
}
|
||
|
||
test("frontend-контракт совпадает с серверным", () => {
|
||
expect(String(PEER_NAME_MIN_LENGTH)).toBe(peerGoConst("PeerNameMinLength"));
|
||
expect(String(PEER_NAME_MAX_LENGTH)).toBe(peerGoConst("PeerNameMaxLength"));
|
||
expect(PEER_NAME_CHARSET).toBe(peerGoConst("PeerNameCharset"));
|
||
expect(PEER_NAME_CHARSET.split(/\s+/).at(-1)).toBe(PEER_NAME_PUNCTUATION);
|
||
|
||
const declaredPattern = peerContract().match(
|
||
/peerNamePattern\s*=\s*regexp\.MustCompile\(`([^`]+)`\)/
|
||
);
|
||
expect(declaredPattern).not.toBeNull();
|
||
expect(PEER_NAME_PATTERN.source).toBe(declaredPattern![1]);
|
||
});
|
||
|
||
test("форма использует общий контракт без собственных копий", () => {
|
||
const form = readSource("views", "peer", "list", "index.vue");
|
||
expect(form).toContain("@/constants/peer");
|
||
expect(form).toContain("pattern: PEER_NAME_PATTERN");
|
||
expect(form).toContain("min: PEER_NAME_MIN_LENGTH");
|
||
expect(form).toContain("max: PEER_NAME_MAX_LENGTH");
|
||
expect(form).toContain("charset: PEER_NAME_CHARSET");
|
||
expect(form).toContain("punctuation: PEER_NAME_PUNCTUATION");
|
||
expect(form).not.toMatch(/const\s+PEER_NAME_PATTERN\s*=/);
|
||
expect(form).not.toContain('charset: "a-z A-Z 0-9');
|
||
});
|
||
|
||
// Подсказка обещала более узкий набор символов, чем принимает сервер: она
|
||
// осталась от правила, действовавшего до EX-03. Оператор не имел причин
|
||
// пробовать разрешённые символы.
|
||
test("подсказка имени описывает действующий набор символов", () => {
|
||
// Набор символов объявлен на сервере ровно один раз; подсказка обязана
|
||
// описывать именно его, а не набор из предыдущего поколения правила.
|
||
expect(PEER_NAME_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("{min}");
|
||
expect(hint).toContain("{max}");
|
||
expect(hint).not.toContain(PEER_NAME_PUNCTUATION);
|
||
expect(hint).not.toContain(
|
||
`${PEER_NAME_MIN_LENGTH} to ${PEER_NAME_MAX_LENGTH}`
|
||
);
|
||
expect(hint).not.toContain(
|
||
`от ${PEER_NAME_MIN_LENGTH} до ${PEER_NAME_MAX_LENGTH}`
|
||
);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("контракт Element Plus", () => {
|
||
test("radio-компоненты не используют label как value", () => {
|
||
const deprecated: string[] = [];
|
||
|
||
for (const file of sourceFiles(FRONTEND_SRC).filter((candidate) =>
|
||
candidate.endsWith(".vue")
|
||
)) {
|
||
const source = fs.readFileSync(file, "utf8");
|
||
for (const match of source.matchAll(
|
||
/<el-radio(?:-button)?\b[\s\S]*?>/g
|
||
)) {
|
||
const tag = match[0];
|
||
if (/\blabel\s*=/.test(tag) && !/\bvalue\s*=/.test(tag)) {
|
||
deprecated.push(`${path.relative(REPO_ROOT, file)}: ${tag}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
expect(deprecated).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe("политика frontend-зависимостей", () => {
|
||
test("security overrides находятся в читаемой pnpm конфигурации", () => {
|
||
const manifest = JSON.parse(
|
||
fs.readFileSync(
|
||
path.join(REPO_ROOT, "apps", "frontend", "package.json"),
|
||
"utf8"
|
||
)
|
||
) as Record<string, unknown>;
|
||
expect(manifest.pnpm).toBeUndefined();
|
||
|
||
const workspace = fs.readFileSync(
|
||
path.join(REPO_ROOT, "apps", "frontend", "pnpm-workspace.yaml"),
|
||
"utf8"
|
||
);
|
||
expect(workspace).toMatch(/^overrides:/m);
|
||
for (const [dependency, resolution] of Object.entries({
|
||
lodash: ">=4.18.1",
|
||
"lodash-es": ">=4.18.1",
|
||
"brace-expansion": ">=1.1.18",
|
||
picomatch: ">=2.3.2",
|
||
"js-yaml": ">=4.3.1",
|
||
"table>ajv": ">=8.18.0",
|
||
browserslist: "4.28.7",
|
||
})) {
|
||
const dependencyKey = dependency.includes(">")
|
||
? `"${dependency}"`
|
||
: dependency;
|
||
expect(workspace).toContain(`${dependencyKey}: "${resolution}"`);
|
||
}
|
||
});
|
||
});
|