fix(rc5): harden frontend runtime and diagnostics

This commit is contained in:
2026-09-08 05:22:06 +05:00
parent 079094591b
commit b7e478f014
26 changed files with 707 additions and 169 deletions
+201 -42
View File
@@ -17,6 +17,13 @@ import {
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";
@@ -310,9 +317,9 @@ describe("страницы журнала", () => {
// Без width/min-width Element Plus делит ширину практически поровну, и
// сообщение — единственное содержимое журнала — получает треть строки.
const columns = [...viewer.matchAll(/<el-table-column[\s\S]*?(?:\/>|>)/g)].map(
(match) => match[0]
);
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)=/);
@@ -408,7 +415,9 @@ describe("действия над пиром", () => {
// вызов здесь больше не стоит, обязан называть его по имени — иначе он
// бесполезен. Это тот же урок, что записан в code_without_comments в
// acceptance.sh, и он повторился прямо на этой проверке.
const confirms = [...codeOf(peerList()).matchAll(/ElMessageBox\.confirm\(/g)];
const confirms = [
...codeOf(peerList()).matchAll(/ElMessageBox\.confirm\(/g),
];
// Единственный вызов — внутри confirmAction, который переводит отмену в
// обычное `false`.
expect(confirms.length).toBe(1);
@@ -523,7 +532,9 @@ describe("страница конфигурации Hysteria", () => {
// Сам модуль view-модели удалён вместе с дефолтами: пока он существует,
// его легко подключить обратно.
expect(
fs.existsSync(path.join(FRONTEND_SRC, "api", "config", "hysteriaViewModel.ts"))
fs.existsSync(
path.join(FRONTEND_SRC, "api", "config", "hysteriaViewModel.ts")
)
).toBe(false);
});
@@ -532,7 +543,12 @@ describe("страница конфигурации Hysteria", () => {
test("страница не изображает редактируемую форму", () => {
const source = vueCodeOf(configPage());
for (const editable of ["<el-input", "<el-switch", "<el-select", "v-model"]) {
for (const editable of [
"<el-input",
"<el-switch",
"<el-select",
"v-model",
]) {
expect(source).not.toContain(editable);
}
});
@@ -606,7 +622,15 @@ describe("страница конфигурации Hysteria", () => {
expect(types).not.toMatch(/\bsecret\s*[?]?:/);
// Секции, которых production-профиль не описывает, в модели ответа не
// объявляются вовсе: они попадают в `drift` списком имён.
for (const section of ["outbounds", "masquerade", "resolver", "sniff", "acl", "mimic", "realm"]) {
for (const section of [
"outbounds",
"masquerade",
"resolver",
"sniff",
"acl",
"mimic",
"realm",
]) {
expect(types).not.toContain(`${section}:`);
}
});
@@ -651,10 +675,14 @@ describe("страница конфигурации Hysteria", () => {
// существуют, «универсальный редактор 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, "components", component))
).toBe(false);
}
expect(
fs.existsSync(path.join(FRONTEND_SRC, "views", "hysteria", "list", "components"))
fs.existsSync(
path.join(FRONTEND_SRC, "views", "hysteria", "list", "components")
)
).toBe(false);
});
});
@@ -701,7 +729,9 @@ describe("контракт учётных данных администрато
if (numeric) {
return numeric[1];
}
const backticked = source.match(new RegExp(`\\b${name}\\s*=\\s*\`([^\`]*)\``));
const backticked = source.match(
new RegExp(`\\b${name}\\s*=\\s*\`([^\`]*)\``)
);
if (backticked) {
return backticked[1];
}
@@ -716,22 +746,37 @@ describe("контракт учётных данных администрато
// принял бы, и оператор остаётся снаружи с паролем, который на самом деле
// подходит.
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"));
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(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}\\}\\$$`), "");
.replace(
new RegExp(
`\\]\\{${ADMIN_USERNAME_MIN_LENGTH},${ADMIN_USERNAME_MAX_LENGTH}\\}\\$$`
),
""
);
expect(mirrored).toBe(declared);
});
@@ -781,9 +826,8 @@ describe("контракт учётных данных администрато
// Встроенных границ на пароле поэтому быть не должно вовсе: длина
// принадлежит правилу, а не соседям по объекту.
test("на пароле нет встроенных min/max от async-validator", async () => {
const { adminPasswordFormRule } = await import(
"../../apps/frontend/src/constants/credentials"
);
const { adminPasswordFormRule } =
await import("../../apps/frontend/src/constants/credentials");
// Правило одно на обе формы, и границ как СВОЙСТВ правила у него нет: длина
// живёт внутри валидатора, где её можно посчитать правильно.
@@ -810,8 +854,12 @@ describe("контракт учётных данных администрато
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);
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),
@@ -854,7 +902,9 @@ describe("контракт учётных данных администрато
// Соседи запрещённых диапазонов обязаны проходить: правило описывает ровно
// множество systemd, а не окрестность подозрительных значений.
for (const accepted of [0xfdcf, 0xfdf0, 0xfffd, 0x10fffd]) {
expect(isValidAdminPassword(`abcde${String.fromCodePoint(accepted)}`)).toBe(true);
expect(
isValidAdminPassword(`abcde${String.fromCodePoint(accepted)}`)
).toBe(true);
}
// Именно тот случай, ради которого встроенные min/max убраны: три эмодзи —
@@ -868,9 +918,8 @@ describe("контракт учётных данных администрато
// обязательно». Сервер ведёт себя так же (DTO приводит пустой пароль к «не
// задано» до проверки правил).
test("пустой пароль остаётся на правиле required", async () => {
const { adminPasswordFormRule } = await import(
"../../apps/frontend/src/constants/credentials"
);
const { adminPasswordFormRule } =
await import("../../apps/frontend/src/constants/credentials");
const rule = adminPasswordFormRule("format");
const errors: (Error | undefined)[] = [];
@@ -886,11 +935,15 @@ describe("контракт учётных данных администрато
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.toContain(
'ADMIN_USERNAME_PATTERN,\n message: t("credentials.passwordFormat"'
);
expect(code).not.toMatch(/passwordPattern/);
}
// Правило логина — единственное место, где вообще упоминается набор.
expect(vueCodeOf(changePasswordForm())).not.toContain("ADMIN_USERNAME_PATTERN");
expect(vueCodeOf(changePasswordForm())).not.toContain(
"ADMIN_USERNAME_PATTERN"
);
});
// Требование НАЗЫВАЕТСЯ, а не сообщается фактом нарушения: набор символов
@@ -917,7 +970,8 @@ describe("контракт учётных данных администрато
for (const placeholder of ["{min}", "{max}", "{charset}"]) {
expect(reason).toContain(placeholder);
}
const passwordReason = (dictionary as any).error?.code?.admin_password_format ?? "";
const passwordReason =
(dictionary as any).error?.code?.admin_password_format ?? "";
for (const placeholder of ["{min}", "{max}", "{maxBytes}"]) {
expect(passwordReason).toContain(placeholder);
}
@@ -944,11 +998,15 @@ describe("индикация ошибки на форме входа", () => {
const styles = loginForm();
// Индикация переехала на элемент, который оператор и видит полем.
expect(styles).toMatch(/&\.is-error\s*\{[\s\S]*?border-color:\s*var\(--el-color-danger\)/);
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)");
expect(styles).toContain(
":deep(.el-form-item__content .el-input__wrapper)"
);
// Победа специфичностью, а не силой: `!important` перекрыл бы и те
// состояния, о которых правило ничего не знает.
@@ -966,27 +1024,128 @@ describe("индикация ошибки на форме входа", () => {
});
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("подсказка имени описывает действующий набор символов", () => {
// Набор символов объявлен на сервере ровно один раз; подсказка обязана
// описывать именно его, а не набор из предыдущего поколения правила.
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("!@#");
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("32");
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}"`);
}
});
});