fix(rc5): harden frontend runtime and diagnostics
This commit is contained in:
@@ -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}"`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user