Files
HY2XS_flamy/tools/test/frontend-contract.test.ts
T
founder c0a43ae915 fix(admin): закрыть обещания панели, которые продукт не выполнял
Девятый проход, по итогам приёмки v1.0.0-rc1 на живом Debian 13. Общая тема:
интерфейс обещал оператору то, что продукт умел, но до чего не доходило
управление.

Секрет пира. Подпись под полем предлагала оставить его пустым, сервер умел его
сгенерировать, и генерация была недостижима: в go-playground/validator тег
omitempty НЕ пропускает правило, если поле объявлено указателем и указатель не
nil — hasValue считает указатель на пустую строку «значением». Правило min=6
применялось к пустой строке и отказывало. Ловушка закрыта общим шагом
нормализации DTO, а не тегом на одном поле: та же ловушка ломала фильтр списка
пиров, где очищенный крестиком el-input отправляет `?name=`. Граница проходит по
каждому полю отдельно — у remark пустая строка означает «убрать пометку», у
disabled ноль означает «включён».

Отказы. Любая ошибка любого поля превращалась в слово `invalid`, а слой vo
определял код ответа СРАВНЕНИЕМ текста сообщения — тот же антипаттерн, который
запрещён панели, только на сервере. Ответ несёт errors[{code, field, message,
params}]; панель выбирает фразу по коду и подставляет причины под поля.

Сессия. Ветка «войдите заново» была недостижима дважды: сервер отвечает HTTP 200
на любой отказ, поэтому обработчик ошибок axios не вызывался, а условие в нём
проверяло code === "A0230" и поле msg, которых в этом API никогда не было.
Истёкший токен вдобавок уезжал с кодом системной ошибки.

Иконки. Контракт currentColor был объявлен в двух местах и не действовал: восемь
ассетов несли литеральный fill="#000000" на <path>, а атрибут представления
перебивает унаследованное CSS-свойство. Под это попадали все семь иконок
бокового меню на фоне #181818.

Имя пира. Два правила на одном поле противоречили друг другу (min=1 против
6-32), а копия набора символов в слое контроллеров несла неэкранированный дефис
и впускала `, - . / : ; <` — через панель проходило имя peer/name, которое
импорт того же пира отклонял. Набор символов ЛОГИНА сознательно не сужен и
закреплён тестом: он приходит из HY2XS_ADMIN_USER и оркестратором не
ограничивается.

Добавлены подпись «Разработано во Flamy» с адресом, принадлежащим приложению, и
контрактные тесты панели как обязательный шаг сборки. Их исполняет Bun, а не
vitest: jsdom не вычисляет currentColor и визуальной корректности не доказал бы,
зато vitest привёл бы в граф pnpm audit сотню транзитивных зависимостей.

docs/ разложена по слоям, 11-testing-and-acceptance.md (117 КБ) разбит на пять
частей, добавлен docs/acceptance/ с отчётом о прогоне rc1 и перечнем дефектов.
Обход документации в приёмке стал рекурсивным: плоский docs/*.md после
разнесения по каталогам совпадал бы ровно с одним файлом.
2026-09-01 07:27:15 +05:00

244 lines
10 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)");
});
});