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 { const source = fs.readFileSync( path.join(REPO_ROOT, "apps", "model", "constant", "error.go"), "utf8" ); const codes = new Set(); 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).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(); 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(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(" { const footer = fs.readFileSync( path.join(FRONTEND_SRC, "layout", "components", "Sidebar", "Footer.vue"), "utf8" ); const anchors = [...footer.matchAll(//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, а в `