diff --git a/CHANGELOG.md b/CHANGELOG.md index 81c0c15..fa66075 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,29 @@ ## [Unreleased] +Runtime-разбор RC4 закрыл три дефекта панели и один связанный риск +сопровождения. Набор знаков имени пира больше не хранится как сырой `@` внутри +message format: общий frontend-контракт передаёт его в RU/EN-подсказку через +named interpolation и сверяется с Go-константами. Builder после frozen install +компилирует реальным `vue-i18n` каждую строку обоих словарей, перехватывая как +исключения, так и compiler diagnostics в `console.error`; удалить этот gate и +при этом получить `tests_gate=true` нельзя. Сам `vue-i18n` переведён с +неподдерживаемой ветки 9.14.5 на поддерживаемую 11.4.10. + +Попутный аудит package manager устранил ложную декларацию security overrides: +pnpm предупреждал, что больше не читает настройки из поля `pnpm` в +`package.json`. Правила перенесены в `apps/frontend/pnpm-workspace.yaml`, то +есть теперь не только сохранены в старом lock-файле, но и действительно +участвуют в следующем разрешении графа. + +Dashboard использует `value` у `el-radio-button` вместо deprecated-семантики +«`label` как значение», а общий frontend-контракт запрещает вернуть старый API +в любом Vue-компоненте. ENV-redaction теперь имеет одно точное исключение для +диагностического boolean `HY2XS_FORCE_PASSWORD_CHANGE`: корректное значение +`true`/`false` видно в bundle, но другое значение либо совпадение по +префиксу/суффиксу исключения не даёт, и пароли, токены, секреты Hysteria и +credentials продолжают заменяться на ``. + По результатам runtime-проверки RC3 login smoke приведён к реальному HTTP контуру панели: positive и negative пробы используют один helper с wire-полем `pass`, явным `HY2XS-Installer/1.0` User-Agent и строгим разбором JSON envelope. diff --git a/README.md b/README.md index c8901e4..8be4152 100644 --- a/README.md +++ b/README.md @@ -771,6 +771,11 @@ hy2xs-orchestrator diagnostics collect \ декодируется с заменой и не попадает в архив; вместо него записывается безопасная причина пропуска. +`HY2XS_FORCE_PASSWORD_CHANGE` — диагностический boolean, поэтому его значение +`true`/`false` сохраняется. Исключение точное и не распространяется на другие +ключи с `PASSWORD`: начальный пароль администратора, `ADMIN_CON_PASS`, пароль +obfs и остальные секреты по-прежнему заменяются на ``. + Архив создаётся в `/var/lib/hy2xs/diagnostics` с режимом `0600`. Этот каталог принадлежит `root:root`, имеет режим `0700` и отделён от `HY2XS_LOG_DIR`, которым владеет сервисный пользователь `hy2xs-admin`. @@ -1008,17 +1013,18 @@ export GITHUB_TOKEN= 1. проверяет контракт `versions.env` (`verify_versions_contract`); 2. прогоняет тесты и типы оркестратора (`bun test`, `tsc --noEmit`); -3. прогоняет контрактные тесты панели (спрайт иконок, словари локализации, коды ошибок, атрибуция); +3. прогоняет dependency-free контракты панели (спрайт иконок, совпадение словарей, коды ошибок, атрибуция и frontend/Go-контракты); 4. определяет последнюю стабильную версию Hysteria, берёт ожидаемый SHA‑256 из upstream `hashes.txt` и сверяет с ним скачанный артефакт; 5. проходит compatibility gate: реальный бинарник Hysteria должен принять канонический конфиг HY2XS для Gecko и для Salamander; -6. собирает orchestrator, frontend и backend, проставляя версию админки из контракта; -7. прогоняет `go vet` и `go test` для HY2XS admin; -8. проверяет граф зависимостей на известные уязвимости (`govulncheck ./...` и `pnpm audit` по всему lock‑графу); -9. формирует архив и прогоняет acceptance‑проверки. +6. собирает standalone-бинарник orchestrator; +7. устанавливает frontend lock-граф, runtime-компилирует каждое сообщение RU/EN реальным `vue-i18n`, затем проверяет типы и собирает frontend и backend, проставляя версию админки из контракта; +8. прогоняет `go vet` и `go test` для HY2XS admin; +9. проверяет граф зависимостей на известные уязвимости (`govulncheck ./...` и `pnpm audit` по всему lock‑графу); +10. формирует архив и прогоняет acceptance‑проверки. -Любой сбой на шагах 1–8 останавливает сборку до создания пакета. +Любой сбой на шагах 1–9 останавливает сборку до создания пакета. -Тесты и типы (шаги 2, 3 и 7) — такой же обязательный гейт, как проверка +Тесты и типы (шаги 2, 3, 7 и 8) — такой же обязательный гейт, как проверка зависимостей: переменной, которая их отключает, не существует. Готовый пакет объявляет об этом полем `tests_gate=true` в `metadata/package.env`, и это утверждение опирается на фактический прогон, а не на намерение. @@ -1028,6 +1034,14 @@ export GITHUB_TOKEN= `bun test tools/test/frontend-sprite.test.ts tools/test/frontend-contract.test.ts` запускаются напрямую и tarball не создают. +Runtime-проверка словарей требует установленного frontend lock-графа: + +```bash +cd apps/frontend +pnpm install --frozen-lockfile +bun test test/i18n-runtime.test.ts +``` + Переменные, управляющие выбором версии Hysteria: | Переменная | По умолчанию | Назначение | diff --git a/apps/frontend/README.md b/apps/frontend/README.md index eb77136..e85316a 100644 --- a/apps/frontend/README.md +++ b/apps/frontend/README.md @@ -19,5 +19,11 @@ Frontend собирается production builder'ом из [`tools/build`](../.. ```sh pnpm install --frozen-lockfile -pnpm run build:prod +bun test test/i18n-runtime.test.ts +pnpm run verify ``` + +Runtime-тест обходит все строковые сообщения русского и английского словарей +через установленный `vue-i18n`. Он проверяет не только исключения, но и +compiler diagnostics в `console.error`: некоторые версии компилятора сообщают +ошибку формата без исключения из `t()`. diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 1e07da4..5b5855a 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -28,7 +28,7 @@ "qrcode.vue": "3.4.1", "vue": "^3.5.42", "vue-echarts": "^8.1.0", - "vue-i18n": "^9.14.5", + "vue-i18n": "^11.4.10", "vue-router": "^4.6.4" }, "devDependencies": { @@ -65,17 +65,6 @@ "vue-eslint-parser": "10.4.1", "vue-tsc": "^3.3.11" }, - "pnpm": { - "overrides": { - "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" - } - }, "engines": { "node": "^20.19.0 || >=22.12.0" } diff --git a/apps/frontend/pnpm-lock.yaml b/apps/frontend/pnpm-lock.yaml index 43ead38..9af3e96 100644 --- a/apps/frontend/pnpm-lock.yaml +++ b/apps/frontend/pnpm-lock.yaml @@ -4,15 +4,6 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false -overrides: - 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 - importers: .: @@ -54,8 +45,8 @@ importers: specifier: ^8.1.0 version: 8.1.0(echarts@6.1.0)(vue@3.5.42(typescript@5.9.3)) vue-i18n: - specifier: ^9.14.5 - version: 9.14.5(vue@3.5.42(typescript@5.9.3)) + specifier: ^11.4.10 + version: 11.4.10(vue@3.5.42(typescript@5.9.3)) vue-router: specifier: ^4.6.4 version: 4.6.4(vue@3.5.42(typescript@5.9.3)) @@ -492,17 +483,21 @@ packages: '@iconify/utils@3.1.4': resolution: {integrity: sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==} - '@intlify/core-base@9.14.5': - resolution: {integrity: sha512-5ah5FqZG4pOoHjkvs8mjtv+gPKYU0zCISaYNjBNNqYiaITxW8ZtVih3GS/oTOqN8d9/mDLyrjD46GBApNxmlsA==} - engines: {node: '>= 16'} + '@intlify/core-base@11.4.10': + resolution: {integrity: sha512-+yJ74JRWVJokdgG9zYNMyTSzeNV3O9T4vVxk8PvLFHmI+R/BYA//cITh7vhRK37hWLZ4/kTcKcUz1dlWOpypIg==} + engines: {node: '>= 22'} - '@intlify/message-compiler@9.14.5': - resolution: {integrity: sha512-IHzgEu61/YIpQV5Pc3aRWScDcnFKWvQA9kigcINcCBXN8mbW+vk9SK+lDxA6STzKQsVJxUPg9ACC52pKKo3SVQ==} - engines: {node: '>= 16'} + '@intlify/devtools-types@11.4.10': + resolution: {integrity: sha512-xZxzZsAuu6/0zoLRVQWdpXWe5Kjl0LnWpjlQA3r9u9FbLYMhapqt7IwkgQyn0Tm2GUNAqhj9eZiUmYOrB024BQ==} + engines: {node: '>= 22'} - '@intlify/shared@9.14.5': - resolution: {integrity: sha512-9gB+E53BYuAEMhbCAxVgG38EZrk59sxBtv3jSizNL2hEWlgjBjAw1AwpLHtNaeda12pe6W20OGEa0TwuMSRbyQ==} - engines: {node: '>= 16'} + '@intlify/message-compiler@11.4.10': + resolution: {integrity: sha512-oUB/scz2EJENXDiUJ7JjZffOrH8UIZ1BuZeHvonbi5fWLavLt04aivuk2OIByOZA0tsci1bkeeQRmwhb5M8Imw==} + engines: {node: '>= 22'} + + '@intlify/shared@11.4.10': + resolution: {integrity: sha512-FeImVdPeoSHTm3NBFFZHv0eRP9gQ3F4lj2puDBX5Kw7iiM1uJW6JTf39ian0K/17pbXCI3ef5i9RVsRrALqI6Q==} + engines: {node: '>= 22'} '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1525,7 +1520,7 @@ packages: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} peerDependencies: - picomatch: '>=2.3.2' + picomatch: ^3 || ^4 peerDependenciesMeta: picomatch: optional: true @@ -1789,8 +1784,8 @@ packages: resolution: {integrity: sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==} peerDependencies: '@types/lodash-es': '*' - lodash: '>=4.18.1' - lodash-es: '>=4.18.1' + lodash: '*' + lodash-es: '*' lodash.truncate@4.4.2: resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==} @@ -1957,6 +1952,10 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + picomatch@4.0.7: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} @@ -2406,7 +2405,7 @@ packages: resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==} hasBin: true peerDependencies: - browserslist: 4.28.7 + browserslist: '>= 4.21.0' uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -2483,10 +2482,9 @@ packages: peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - vue-i18n@9.14.5: - resolution: {integrity: sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g==} - engines: {node: '>= 16'} - deprecated: v9 and v10 no longer supported. please migrate to v11. about maintenance status, see https://vue-i18n.intlify.dev/guide/maintenance.html + vue-i18n@11.4.10: + resolution: {integrity: sha512-Lp+BjOxqzOY87DS6Z8KrQrpiTr9IN/Lt4kZEilwyXG2Wrx+AcU6IVsAW92HNXtVcn1HFFPV6ty41p9e/qDpyvg==} + engines: {node: '>= 22'} peerDependencies: vue: ^3.0.0 @@ -2782,17 +2780,23 @@ snapshots: '@iconify/types': 2.0.0 import-meta-resolve: 4.2.0 - '@intlify/core-base@9.14.5': + '@intlify/core-base@11.4.10': dependencies: - '@intlify/message-compiler': 9.14.5 - '@intlify/shared': 9.14.5 + '@intlify/devtools-types': 11.4.10 + '@intlify/message-compiler': 11.4.10 + '@intlify/shared': 11.4.10 - '@intlify/message-compiler@9.14.5': + '@intlify/devtools-types@11.4.10': dependencies: - '@intlify/shared': 9.14.5 + '@intlify/core-base': 11.4.10 + '@intlify/shared': 11.4.10 + + '@intlify/message-compiler@11.4.10': + dependencies: + '@intlify/shared': 11.4.10 source-map-js: 1.2.1 - '@intlify/shared@9.14.5': {} + '@intlify/shared@11.4.10': {} '@jridgewell/gen-mapping@0.3.13': dependencies: @@ -4113,7 +4117,7 @@ snapshots: micromatch@4.0.8: dependencies: braces: 3.0.3 - picomatch: 4.0.7 + picomatch: 2.3.2 mime-db@1.52.0: {} @@ -4245,6 +4249,8 @@ snapshots: picocolors@1.1.1: {} + picomatch@2.3.2: {} + picomatch@4.0.7: {} pinia@2.3.1(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)): @@ -4798,10 +4804,11 @@ snapshots: transitivePeerDependencies: - supports-color - vue-i18n@9.14.5(vue@3.5.42(typescript@5.9.3)): + vue-i18n@11.4.10(vue@3.5.42(typescript@5.9.3)): dependencies: - '@intlify/core-base': 9.14.5 - '@intlify/shared': 9.14.5 + '@intlify/core-base': 11.4.10 + '@intlify/devtools-types': 11.4.10 + '@intlify/shared': 11.4.10 '@vue/devtools-api': 6.6.4 vue: 3.5.42(typescript@5.9.3) diff --git a/apps/frontend/pnpm-workspace.yaml b/apps/frontend/pnpm-workspace.yaml new file mode 100644 index 0000000..4f062e6 --- /dev/null +++ b/apps/frontend/pnpm-workspace.yaml @@ -0,0 +1,13 @@ +# Настройки resolution принадлежат pnpm, а не npm package manifest. +# Единственный workspace-проект — сам frontend. +packages: + - "." + +overrides: + 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" diff --git a/apps/frontend/src/constants/peer.ts b/apps/frontend/src/constants/peer.ts new file mode 100644 index 0000000..b488853 --- /dev/null +++ b/apps/frontend/src/constants/peer.ts @@ -0,0 +1,29 @@ +/** + * Контракт имени пира на стороне панели. + * + * Сервер остаётся конечным авторитетом: эти значения зеркалят константы из + * apps/service/peer_import.go и сверяются с ними release-тестом. Общий модуль + * не даёт форме, сообщению об ошибке и подсказке завести разные копии правила. + */ + +export const PEER_NAME_MIN_LENGTH = 6; +export const PEER_NAME_MAX_LENGTH = 32; + +/** Набор знаков без букв и цифр — передаётся в i18n как значение. */ +export const PEER_NAME_PUNCTUATION = "!@#$%^&*()_+-="; + +/** Полный набор в том виде, в каком его показывают оператору. */ +export const PEER_NAME_CHARSET = `a-z A-Z 0-9 ${PEER_NAME_PUNCTUATION}`; + +/** + * Набор символов в записи регулярного выражения. + * + * Дефис экранирован намеренно: неэкранированное `+-=` становится диапазоном и + * принимает лишние знаки. Числовые границы берутся из тех же констант, что и + * тексты формы. + */ +const PEER_NAME_CHARACTER_CLASS = "a-zA-Z0-9!@#$%^&*()_+\\-="; + +export const PEER_NAME_PATTERN = new RegExp( + `^[${PEER_NAME_CHARACTER_CLASS}]{${PEER_NAME_MIN_LENGTH},${PEER_NAME_MAX_LENGTH}}$` +); diff --git a/apps/frontend/src/lang/package/en.ts b/apps/frontend/src/lang/package/en.ts index 0e87f58..4502e19 100644 --- a/apps/frontend/src/lang/package/en.ts +++ b/apps/frontend/src/lang/package/en.ts @@ -60,7 +60,8 @@ export default { trafficApiUnreachable: "Unreachable", security: { hysteriaStopped: "Hysteria2 is stopped", - hysteriaStateUnknown: "Hysteria2 service state is unknown: systemd did not answer", + hysteriaStateUnknown: + "Hysteria2 service state is unknown: systemd did not answer", trafficApiUnavailable: "Traffic API is unavailable", }, error: { @@ -217,7 +218,7 @@ export default { // The character set is stated as service.IsValidPeerName accepts it. The // previous wording described the rule that was in force before EX-03. nameHint: - "Peer identifier: 6 to 32 characters — latin letters, digits and !@#$%^&*()_+-=. The name becomes part of the auto-generated secret and is shown to the client as the profile name.", + "Peer identifier: {min} to {max} characters — latin letters, digits and {punctuation}. The name becomes part of the auto-generated secret and is shown to the client as the profile name.", remarkPlaceholder: "laptop", remarkHint: "Optional operator note. It is never shown to the client.", secretPlaceholder: "leave empty to generate one", diff --git a/apps/frontend/src/lang/package/ru.ts b/apps/frontend/src/lang/package/ru.ts index d3883c2..73e04a8 100644 --- a/apps/frontend/src/lang/package/ru.ts +++ b/apps/frontend/src/lang/package/ru.ts @@ -240,7 +240,7 @@ export default { // действовавшего до EX-03: подсказка обещала более узкий набор, чем // сервер принимает, и оператор не имел причин пробовать остальные. nameHint: - "Идентификатор пира: от 6 до 32 символов — латиница, цифры и знаки !@#$%^&*()_+-=. Имя попадает в автогенерируемый секрет и показывается клиенту как название профиля.", + "Идентификатор пира: от {min} до {max} символов — латиница, цифры и знаки {punctuation}. Имя попадает в автогенерируемый секрет и показывается клиенту как название профиля.", remarkPlaceholder: "ноутбук", remarkHint: "Необязательная пометка для оператора. Клиент её не видит.", secretPlaceholder: "оставьте пустым — сгенерируем", diff --git a/apps/frontend/src/views/dashboard/index.vue b/apps/frontend/src/views/dashboard/index.vue index 1cf8c3d..0f8ef11 100644 --- a/apps/frontend/src/views/dashboard/index.vue +++ b/apps/frontend/src/views/dashboard/index.vue @@ -98,7 +98,9 @@ {{ $t("dashboard.onlinePeers") }}: - {{ summary.hysteria.apiReachable ? summary.peers.onlinePeers : "?" }} {{ $t("dashboard.trafficChart") }} - 1h - 24h - 7d + 1h + 24h + 7d diff --git a/apps/frontend/src/views/peer/list/index.vue b/apps/frontend/src/views/peer/list/index.vue index 368780c..95ba16f 100644 --- a/apps/frontend/src/views/peer/list/index.vue +++ b/apps/frontend/src/views/peer/list/index.vue @@ -247,7 +247,15 @@ v-model="dataForm.name" :placeholder="$t('peer.form.namePlaceholder')" /> -
{{ $t("peer.form.nameHint") }}
+
+ {{ + $t("peer.form.nameHint", { + min: PEER_NAME_MIN_LENGTH, + max: PEER_NAME_MAX_LENGTH, + punctuation: PEER_NAME_PUNCTUATION, + }) + }} +
; + +function leafMessages( + value: unknown, + prefix = "" +): Array<[key: string, message: string]> { + if (typeof value === "string") { + return [[prefix, value]]; + } + if (!value || typeof value !== "object") { + return []; + } + return Object.entries(value as Dictionary).flatMap(([key, child]) => + leafMessages(child, prefix ? `${prefix}.${key}` : key) + ); +} + +function interpolationValues(message: string): Record { + const values: Record = { + count: 2, + n: 2, + min: PEER_NAME_MIN_LENGTH, + max: PEER_NAME_MAX_LENGTH, + punctuation: PEER_NAME_PUNCTUATION, + }; + for (const match of message.matchAll(/\{([A-Za-z_][A-Za-z0-9_$-]*)\}/g)) { + values[match[1]] ??= match[1]; + } + return values; +} + +for (const [locale, dictionary] of Object.entries({ ru, en }) as Array< + ["ru" | "en", Dictionary] +>) { + describe(`runtime-компиляция локализации ${locale}`, () => { + test("каждое строковое сообщение компилируется без diagnostics", () => { + const compilerDiagnostics: string[] = []; + const thrown: string[] = []; + const originalConsoleError = console.error; + + console.error = (...args: unknown[]) => { + compilerDiagnostics.push(args.map(String).join(" ")); + }; + + try { + const i18n = createI18n({ + legacy: false, + locale, + fallbackLocale: false, + missingWarn: false, + fallbackWarn: false, + messages: { [locale]: dictionary }, + }); + + for (const [key, message] of leafMessages(dictionary)) { + try { + const translated = i18n.global.t(key, interpolationValues(message)); + if (typeof translated !== "string") { + thrown.push(`${key}: результат не является строкой`); + } + } catch (error) { + thrown.push( + `${key}: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + } finally { + console.error = originalConsoleError; + } + + expect(thrown).toEqual([]); + expect(compilerDiagnostics).toEqual([]); + }); + + test("набор знаков имени пира проходит интерполяцию как данные", () => { + const i18n = createI18n({ + legacy: false, + locale, + fallbackLocale: false, + messages: { [locale]: dictionary }, + }); + + const template = (dictionary as any).peer.form.nameHint as string; + expect( + i18n.global.t("peer.form.nameHint", { + min: PEER_NAME_MIN_LENGTH, + max: PEER_NAME_MAX_LENGTH, + punctuation: PEER_NAME_PUNCTUATION, + }) + ).toBe( + template + .replace("{min}", String(PEER_NAME_MIN_LENGTH)) + .replace("{max}", String(PEER_NAME_MAX_LENGTH)) + .replace("{punctuation}", PEER_NAME_PUNCTUATION) + ); + }); + }); +} diff --git a/docs/admin/15-ui-contracts.md b/docs/admin/15-ui-contracts.md index 0000bf9..981ebcc 100644 --- a/docs/admin/15-ui-contracts.md +++ b/docs/admin/15-ui-contracts.md @@ -85,6 +85,12 @@ сборку — vue-i18n молча отдаёт сам ключ, и оператор видит `error.code.min_length` вместо фразы. +Совпадения ключей недостаточно: строки компилируются как Vue I18n message +format только при переводе. Специальные символы (`@`, `$`, `{}`, `|`) нельзя +вставлять в текст как произвольные данные. Динамический набор знаков передаётся +через named interpolation, а builder переводит каждую leaf-строку RU/EN и +считает ошибкой как исключение, так и compiler diagnostics в `console.error`. + **Состояние сессии** сообщается кодами `unauthorized`, `session_expired`, `token_invalid`, `account_disabled` при `code = 50401`. Панель по ним показывает диалог и возвращает на форму входа. @@ -132,6 +138,11 @@ продукт: в таблицу пиров ведут две двери, и они не имеют права требовать разного. +Frontend-зеркало границ, regex и человекочитаемого набора находится в одном +модуле `constants/peer.ts` и сверяется с Go-контрактом тестом. Пунктуация +`!@#$%^&*()_+-=` попадает в подсказку как значение `{punctuation}`, а не как +часть синтаксиса message format. + --- ## 4. Таблицы журнала diff --git a/docs/build/02-build-layer-and-package.md b/docs/build/02-build-layer-and-package.md index 6170d65..e83e214 100644 --- a/docs/build/02-build-layer-and-package.md +++ b/docs/build/02-build-layer-and-package.md @@ -136,7 +136,7 @@ Bun обновляется отдельно от остальных: оркес полного прохода `bun test → tsc → compile → приёмка на Debian`, а не строки в общем патче. -### Проверка типов frontend — обязательный шаг релиза +### Runtime-компиляция локализации и типы frontend — обязательные шаги релиза ```text pnpm run typecheck → vue-tsc --noEmit → ОБЯЗАН пройти @@ -144,9 +144,13 @@ pnpm run build:prod → vite build pnpm run verify → typecheck, затем build ``` -`bundle_ui()` запускает `typecheck` **до** сборки bundle: собирать production -bundle из кода, который не проходит проверку типов, незачем. Порядок и сам факт -наличия шага проверяются приёмкой. +После `pnpm install --frozen-lockfile` функция `bundle_ui()` сначала запускает +`bun test test/i18n-runtime.test.ts`, затем `typecheck` и только потом сборку +bundle. Словари заданы TypeScript-объектами, поэтому Vite и `vue-tsc` не +компилируют message format и не ловят специальные символы `@`, `$`, `{}`, `|`. +Runtime-gate использует реальный `vue-i18n` из lock-графа, обходит все строки +RU/EN и перехватывает также `console.error`: один лишь `try/catch` недостаточен, +поскольку компилятор может сообщить об ошибке без исключения из `t()`. До v1 этой гарантии не было. `build:prod` означал `vite build && vue-tsc --noEmit`, но `vue-tsc` был версии `0.35.0` (2022 год) и шаблоны Vue @@ -222,7 +226,9 @@ bundle из кода, который не проходит проверку ти Уязвимость обычно приходит не из прямой зависимости, а из чужого поддерева, и поднимать ради неё весь frontend-граф не нужно. Такие случаи закрываются -списком `pnpm.overrides` в `apps/frontend/package.json`: +списком `overrides` в `apps/frontend/pnpm-workspace.yaml`. Настройки pnpm не +хранятся в поле `pnpm` package manifest: современные версии его игнорируют, и +правило осталось бы только случайно замороженным в старом lock-файле. | override | зачем | | --- | --- | @@ -253,14 +259,15 @@ bundle из кода, который не проходит проверку ти | Шаг сборки | Что запускается | | --- | --- | | `run_orchestrator_tests` | `bun x tsc --noEmit`, `bun test` | -| `bundle_ui` | `pnpm run typecheck` (`vue-tsc --noEmit`) до сборки bundle | +| `run_frontend_tests` | dependency-free контракты панели | +| `bundle_ui` | frozen install → runtime-компиляция всех RU/EN-сообщений → `pnpm run typecheck` → production bundle | | `run_admin_tests` | `go vet ./...`, `go test ./...` | Готовый пакет объявляет об этом полем `tests_gate=true` в `metadata/package.env` — так же, как `dependency_security_gate` и `hysteria_compat_gate`. Значение у поля ровно одно, потому что не бывает -пакета, собранного с пропущенными тестами: обе функции прогона выставляют свой -флаг **после** успешного завершения, а `write_metadata` отказывается писать +пакета, собранного с пропущенными тестами: функции прогона выставляют свои +флаги **после** успешного завершения, а `write_metadata` отказывается писать метаданные, если хотя бы один из них не выставлен. То есть поле остаётся утверждением о результате, а не переключателем. diff --git a/docs/operations/13-production-runbook.md b/docs/operations/13-production-runbook.md index c5aecfb..8fcb696 100644 --- a/docs/operations/13-production-runbook.md +++ b/docs/operations/13-production-runbook.md @@ -346,6 +346,8 @@ hy2xs-orchestrator redact-config --config /etc/hysteria/config.yaml --out /root/ редакцию, а результат остаётся валидным YAML; - в env-файлах секрет вырезается и из URL-значения, даже если имя ключа несекретное — например, `HY2_AUTH_URL` в `post-install.env`. +- `HY2XS_FORCE_PASSWORD_CHANGE=true|false` сохраняется как диагностический + boolean; это точное исключение, остальные ключи с `PASSWORD` редактируются. Та же редакция применяется к diagnostics-бандлу (`hy2xs-orchestrator diagnostics collect`), который собирается автоматически при diff --git a/docs/runtime/09-post-install-env.md b/docs/runtime/09-post-install-env.md index 23de2d8..bc16eed 100644 --- a/docs/runtime/09-post-install-env.md +++ b/docs/runtime/09-post-install-env.md @@ -144,6 +144,13 @@ anycast. Отсутствие A-записи фатально при любом - `HY2XS_FORCE_PASSWORD_CHANGE` в production baseline установлен в `false` (forced UX-flow пока не реализован); - после первичного seed перезапуски `hy2xs-admin` не должны переопределять пароль admin и `con_pass`. +В санитизированной диагностике корректное значение `true`/`false` у +`HY2XS_FORCE_PASSWORD_CHANGE` остаётся видимым: это boolean состояния, а не +пароль. Исключение применяется только к точному имени ключа и допустимому +boolean; неизвестное значение скрывается fail-closed. +`HY2XS_ADMIN_INITIAL_PASSWORD`, `HY2XS_ADMIN_CON_PASS`, пароль obfs, +traffic-stats secret и похожие ключи продолжают редактироваться. + ### Учётные данные администратора проверяются при разборе окружения `HY2XS_ADMIN_USER` и `HY2XS_ADMIN_INITIAL_PASSWORD` — это значения, которые diff --git a/docs/testing/11-1-how-to-run.md b/docs/testing/11-1-how-to-run.md index 36fff3d..42580c4 100644 --- a/docs/testing/11-1-how-to-run.md +++ b/docs/testing/11-1-how-to-run.md @@ -18,9 +18,12 @@ cd apps && go vet ./... && go test ./... # Проверка типов и сборка frontend cd apps/frontend && pnpm install --frozen-lockfile && pnpm run verify -# Контракты панели: спрайт иконок, словари локализации, коды ошибок, атрибуция +# Dependency-free контракты панели bun test tools/test/frontend-sprite.test.ts tools/test/frontend-contract.test.ts +# Runtime-компиляция всех RU/EN-сообщений реальным vue-i18n +cd apps/frontend && bun test test/i18n-runtime.test.ts + # Сверка среды разработки с versions.env (ничего не меняет) ./tools/dev/doctor.sh @@ -36,9 +39,12 @@ HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh ## Почему контракты панели проверяет Bun, а не vitest -Проверяемые модули (`SvgIcon/symbol.ts`, `constants/branding.ts`, словари -локализации) намеренно чистые: ни Vite, ни DOM в них нет, поэтому их можно -выполнить вне браузера уже закреплённым в `versions.env` Bun. +Dependency-free модули (`SvgIcon/symbol.ts`, frontend constants и словари +локализации) намеренно чистые: ни Vite, ни DOM в них нет. Отдельный +`i18n-runtime.test.ts` запускается после `pnpm install`, потому что обязан +использовать именно `vue-i18n` из frozen lock-графа. Он обходит каждую строку +обоих словарей и считает отказом и исключение, и compiler diagnostics в +`console.error`. Vitest с jsdom не вычисляет `currentColor` и визуальной корректности всё равно не доказал бы, зато привёл бы в граф `pnpm audit` — а его порог считается по diff --git a/docs/testing/11-2-builder-layer.md b/docs/testing/11-2-builder-layer.md index 9fdfa0b..5329bac 100644 --- a/docs/testing/11-2-builder-layer.md +++ b/docs/testing/11-2-builder-layer.md @@ -918,7 +918,8 @@ build tooling исполняется на build-машине и порождае | Шаг сборки | Что запускается | | --- | --- | | `run_orchestrator_tests` | `bun x tsc --noEmit`, `bun test` | -| `bundle_ui` | `pnpm run typecheck` до сборки bundle | +| `run_frontend_tests` | dependency-free контракты панели | +| `bundle_ui` | после frozen install: `bun test test/i18n-runtime.test.ts`, затем `pnpm run typecheck` и bundle | | `run_admin_tests` | `go vet ./...`, `go test ./...` | Приёмка проверяет: @@ -926,9 +927,13 @@ build tooling исполняется на build-машине и порождае - отключающей тесты переменной нет ни в одном модуле сборки, ни в README/docs (место для истории — `CHANGELOG.md`); - `metadata/package.env` содержит `tests_gate=true`; +- общий frontend-флаг выставляется только после ранних контрактов и + runtime-компиляции всех сообщений RU/EN; +- runtime-gate стоит между frozen install и typecheck/build, использует + `vue-i18n` из lock-графа и считает ошибкой compiler diagnostics; - утверждение о прогоне выставляется **после** самого прогона, а не до него; -- `write_metadata` отказывается писать метаданные, если хотя бы один из двух - прогонов не подтверждён. +- `write_metadata` отказывается писать метаданные, если хотя бы одна из + обязательных групп проверок не подтверждена. ## A12. Приёмка проверяет код, а не упоминания diff --git a/orchestrator/src/lib/redaction.ts b/orchestrator/src/lib/redaction.ts index 3339a90..e93ccca 100644 --- a/orchestrator/src/lib/redaction.ts +++ b/orchestrator/src/lib/redaction.ts @@ -12,6 +12,17 @@ export const REDACTED = ""; +/** + * ENV-флаги, чьи имена похожи на секрет, но значения являются диагностически + * полезным публичным состоянием. Исключения здесь только точные: общий + * детектор секретов нельзя ослаблять совпадением по префиксу или суффиксу. + */ +const NON_SECRET_ENV_KEYS = new Set(["HY2XS_FORCE_PASSWORD_CHANGE"]); + +/** Сохранённый контракт прежнего ENV-детектора, но применённый после разбора key. */ +const ENV_SECRET_KEY_PATTERN = + /(PASSWORD|PASSWD|PASSPHRASE|PASS|SECRET|TOKEN|CREDENTIAL|APIKEY|AUTHORIZATION|COOKIE|BEARER|SIGNATURE)/; + /** * Маркеры имён полей. Список покрывает известные секреты HY2XS/Hysteria и * типовые имена, под которыми секрет может появиться в будущем upstream-поле. @@ -36,11 +47,14 @@ export const SECRET_KEY_MARKERS: readonly string[] = [ "authorization", "cookie", "bearer", - "signature" + "signature", ]; /** Узлы, где секретом являются все значения карты, а не имя ключа. */ -const SECRET_MAP_PATHS: readonly string[] = ["auth.userpass", "acme.dns.config"]; +const SECRET_MAP_PATHS: readonly string[] = [ + "auth.userpass", + "acme.dns.config", +]; export function isSecretKey(key: string): boolean { const lowered = key.toLowerCase(); @@ -92,7 +106,9 @@ function redactValueDeep(value: unknown): unknown { } if (value && typeof value === "object") { const out: Record = {}; - for (const [key, child] of Object.entries(value as Record)) { + for (const [key, child] of Object.entries( + value as Record + )) { out[key] = redactValueDeep(child); } return out; @@ -138,8 +154,15 @@ function redactNode(value: unknown, path: readonly string[]): unknown { export function redactKeyValueSecrets(content: string): string { return content.replace( - /^([A-Z0-9_]*(PASSWORD|PASSWD|PASSPHRASE|PASS|SECRET|TOKEN|CREDENTIAL|APIKEY|AUTHORIZATION|COOKIE|BEARER|SIGNATURE)[A-Z0-9_]*=).*$/gm, - `$1${REDACTED}` + /^([A-Z0-9_]+)=(.*?)(\r?)$/gm, + (line, key: string, value: string, eol: string) => { + const isKnownBoolean = + NON_SECRET_ENV_KEYS.has(key) && /^(?:true|false)$/.test(value); + if (isKnownBoolean || !ENV_SECRET_KEY_PATTERN.test(key)) { + return line; + } + return `${key}=${REDACTED}${eol}`; + } ); } @@ -207,7 +230,10 @@ export function redactLogText(content: string): string { LOG_SECRET_ASSIGNMENT_PATTERN, (match, key: string, separator: string, rawValue: string) => { const [value, trailing] = splitTrailingPunctuation(rawValue); - if (value === REDACTED || value.toLowerCase() === ENCODED_REDACTED.toLowerCase()) { + if ( + value === REDACTED || + value.toLowerCase() === ENCODED_REDACTED.toLowerCase() + ) { return match; } const rendered = separator === ":" ? ": " : "="; diff --git a/orchestrator/test/diagnostics-safety.test.ts b/orchestrator/test/diagnostics-safety.test.ts index 13718c0..ec09747 100644 --- a/orchestrator/test/diagnostics-safety.test.ts +++ b/orchestrator/test/diagnostics-safety.test.ts @@ -9,7 +9,11 @@ import { redactEnv } from "../src/lib/redaction"; const directories: string[] = []; afterEach(async () => { - await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); + await Promise.all( + directories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })) + ); }); async function source(bytes: Uint8Array): Promise { @@ -22,14 +26,31 @@ async function source(bytes: Uint8Array): Promise { describe("безопасная подготовка diagnostics", () => { test("секрет редактируется до выдачи текста на запись", async () => { - const path = await source(new TextEncoder().encode("HY2XS_ADMIN_CON_PASS=top-secret\n")); + const path = await source( + new TextEncoder().encode("HY2XS_ADMIN_CON_PASS=top-secret\n") + ); const prepared = await prepareDiagnosticFile(path, redactEnv); expect(prepared).toContain(""); expect(prepared).not.toContain("top-secret"); }); + test("диагностический boolean виден, а соседний пароль скрыт", async () => { + const path = await source( + new TextEncoder().encode( + "HY2XS_FORCE_PASSWORD_CHANGE=false\nHY2XS_ADMIN_INITIAL_PASSWORD=top-secret\n" + ) + ); + const prepared = await prepareDiagnosticFile(path, redactEnv); + + expect(prepared).toContain("HY2XS_FORCE_PASSWORD_CHANGE=false"); + expect(prepared).toContain("HY2XS_ADMIN_INITIAL_PASSWORD="); + expect(prepared).not.toContain("top-secret"); + }); + test("повреждённый UTF-8 заменяется безопасной причиной, а не U+FFFD", async () => { - const path = await source(new Uint8Array([0x53, 0x45, 0x43, 0x52, 0x45, 0x54, 0x3d, 0xff])); + const path = await source( + new Uint8Array([0x53, 0x45, 0x43, 0x52, 0x45, 0x54, 0x3d, 0xff]) + ); const prepared = await prepareDiagnosticFile(path, redactEnv); expect(prepared).toContain("некорректный UTF-8"); expect(prepared).not.toContain("SECRET="); diff --git a/orchestrator/test/redaction.test.ts b/orchestrator/test/redaction.test.ts index c988fd0..7b53a64 100644 --- a/orchestrator/test/redaction.test.ts +++ b/orchestrator/test/redaction.test.ts @@ -6,7 +6,7 @@ import { redactLogText, redactYaml, redactYamlFallback, - sanitizeUrlValue + sanitizeUrlValue, } from "../src/lib/redaction"; const MACHINE_TOKEN = "hy2xs-machine-token-must-not-leak"; @@ -58,7 +58,10 @@ describe("редакция серверного конфига", () => { }); test("результат остаётся валидным YAML", () => { - const parsed = Bun.YAML.parse(redactYaml(SERVER_CONFIG)) as Record; + const parsed = Bun.YAML.parse(redactYaml(SERVER_CONFIG)) as Record< + string, + unknown + >; expect(parsed.listen).toBe("0.0.0.0:443"); const auth = parsed.auth as Record; const http = auth.http as Record; @@ -69,7 +72,10 @@ describe("редакция серверного конфига", () => { }); test("несекретные поля сохраняются: диагностика должна оставаться полезной", () => { - const parsed = Bun.YAML.parse(redactYaml(SERVER_CONFIG)) as Record; + const parsed = Bun.YAML.parse(redactYaml(SERVER_CONFIG)) as Record< + string, + unknown + >; const acme = parsed.acme as Record; expect(acme.email).toBe("admin@example.com"); expect(acme.ca).toBe("letsencrypt"); @@ -92,7 +98,10 @@ describe("редакция серверного конфига", () => { test("вложенное дерево под секретным ключом вырезается целиком", () => { const yaml = `acme:\n dns:\n name: cloudflare\n config:\n api_token: leak-me\n zone: example.com\n`; const parsed = Bun.YAML.parse(redactYaml(yaml)) as Record; - const dns = (parsed.acme as Record).dns as Record; + const dns = (parsed.acme as Record).dns as Record< + string, + unknown + >; const cfg = dns.config as Record; expect(cfg.api_token).toBe(REDACTED); // acme.dns.config — карта, где секретны все значения, а не только ключ. @@ -158,7 +167,7 @@ describe("редакция env-артефактов", () => { "HY2_AUTH_MODE=http", `HY2_AUTH_URL=http://127.0.0.1:8080/internal/hysteria/auth?access_token=${MACHINE_TOKEN}`, `HY2_OBFS_PASSWORD=${OBFS_PASSWORD}`, - "HY2_PORT=443" + "HY2_PORT=443", ].join("\n"); // Регрессия: HY2_AUTH_URL не подходит ни под один key-маркер, но несёт @@ -166,7 +175,9 @@ describe("редакция env-артефактов", () => { test("секрет внутри URL-значения вырезается, даже если имя ключа несекретное", () => { const redacted = redactEnv(POST_INSTALL); expect(redacted).not.toContain(MACHINE_TOKEN); - expect(redacted).toContain("HY2_AUTH_URL=http://127.0.0.1:8080/internal/hysteria/auth"); + expect(redacted).toContain( + "HY2_AUTH_URL=http://127.0.0.1:8080/internal/hysteria/auth" + ); }); test("ключи-секреты вырезаются по имени", () => { @@ -180,13 +191,40 @@ describe("редакция env-артефактов", () => { expect(redacted).toContain("HY2_PORT=443"); }); + test("boolean-флаг принудительной смены пароля остаётся диагностируемым", () => { + const flags = [ + "HY2XS_FORCE_PASSWORD_CHANGE=false", + "HY2XS_FORCE_PASSWORD_CHANGE=true", + ].join("\n"); + + expect(redactEnv(flags)).toBe(flags); + }); + + test("исключение для boolean-флага точное и не ослабляет защиту", () => { + const input = [ + "HY2XS_FORCE_PASSWORD_CHANGE_SUFFIX=must-hide", + "HY2XS_FORCE_PASSWORD_CHANGE=malformed-secret", + "HY2XS_ADMIN_INITIAL_PASSWORD=initial-password", + "HY2XS_ADMIN_CON_PASS=connection-password", + `HY2XS_HYSTERIA_OBFS_PASSWORD=${OBFS_PASSWORD}`, + ].join("\n"); + const redacted = redactEnv(input); + + expect(redacted).not.toContain("must-hide"); + expect(redacted).not.toContain("initial-password"); + expect(redacted).not.toContain("connection-password"); + expect(redacted).not.toContain(OBFS_PASSWORD); + expect(redacted.match(//g)).toHaveLength(5); + }); + test("runtime-конфиг теряет все секреты", () => { const runtime = [ "HY2XS_CONFIG_SCHEMA_VERSION=2", + "HY2XS_FORCE_PASSWORD_CHANGE=false", `HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET=${MACHINE_TOKEN}`, `HY2XS_HYSTERIA_OBFS_PASSWORD=${OBFS_PASSWORD}`, "HY2XS_ADMIN_CON_PASS=connection-password", - "HY2XS_ADMIN_INITIAL_PASSWORD=initial-password" + "HY2XS_ADMIN_INITIAL_PASSWORD=initial-password", ].join("\n"); const redacted = redactEnv(runtime); @@ -195,16 +233,21 @@ describe("редакция env-артефактов", () => { expect(redacted).not.toContain("connection-password"); expect(redacted).not.toContain("initial-password"); expect(redacted).toContain("HY2XS_CONFIG_SCHEMA_VERSION=2"); + expect(redacted).toContain("HY2XS_FORCE_PASSWORD_CHANGE=false"); }); }); describe("санитизация URL", () => { test("креденшелы в URL вырезаются", () => { - expect(sanitizeUrlValue("https://user:hunter2@example.com/x")).not.toContain("hunter2"); + expect( + sanitizeUrlValue("https://user:hunter2@example.com/x") + ).not.toContain("hunter2"); }); test("секретные query-параметры вырезаются, остальные сохраняются", () => { - const out = sanitizeUrlValue("https://example.com/x?access_token=abc&page=2"); + const out = sanitizeUrlValue( + "https://example.com/x?access_token=abc&page=2" + ); expect(out).not.toContain("abc"); expect(out).toContain("page=2"); }); @@ -245,7 +288,7 @@ describe("редакция журналов в diagnostics-бандле", () => for (const line of [ `starting with access_token=${MACHINE_TOKEN}`, `trafficStats secret=${MACHINE_TOKEN} rejected`, - `obfs password: ${OBFS_PASSWORD}` + `obfs password: ${OBFS_PASSWORD}`, ]) { const out = redactLogText(line); expect(out).not.toContain(MACHINE_TOKEN); @@ -288,14 +331,22 @@ describe("маркеры секретных имён", () => { "cookie", "bearer", "signature", - "passphrase" + "passphrase", ]) { expect(isSecretKey(key)).toBe(true); } }); test("не задевают обычные поля", () => { - for (const key of ["listen", "type", "domains", "minPacketSize", "email", "ca", "dir"]) { + for (const key of [ + "listen", + "type", + "domains", + "minPacketSize", + "email", + "ca", + "dir", + ]) { expect(isSecretKey(key)).toBe(false); } }); diff --git a/tools/build/README.md b/tools/build/README.md index 9e7c91f..98094af 100644 --- a/tools/build/README.md +++ b/tools/build/README.md @@ -63,15 +63,16 @@ Windows и macOS можно использовать для редактиров и сверяет каждый архив с контрольной суммой из `versions.env`. 6. Выполняет `verify_versions_contract`: рассинхрон версий роняет сборку до создания tarball. 7. Прогоняет тесты и типы оркестратора (`bun test`, `tsc --noEmit`). -8. Разрешает upstream-версию Hysteria, берёт ожидаемый SHA-256 из upstream `hashes.txt` и сверяет с ним скачанный артефакт. -9. Проходит compatibility gate: реальный бинарник Hysteria должен принять канонический конфиг HY2XS. -10. Копирует package skeleton. -11. Собирает install-only orchestrator в standalone binary. -12. Собирает frontend и backend HY2XS admin в Linux amd64 binary, проставляя версию админки через ldflags. -13. Прогоняет `go vet` и `go test` для HY2XS admin (после сборки frontend, потому что `go:embed all:dist` требует готовых ассетов). -14. Записывает metadata и checksums. -15. Создаёт `dist/hy2xs-install-.tar.gz`. -16. Проверяет архив и прогоняет acceptance-проверки. +8. Рано прогоняет dependency-free контракты панели. +9. Разрешает upstream-версию Hysteria, берёт ожидаемый SHA-256 из upstream `hashes.txt` и сверяет с ним скачанный артефакт. +10. Проходит compatibility gate: реальный бинарник Hysteria должен принять канонический конфиг HY2XS. +11. Копирует package skeleton и собирает install-only orchestrator. +12. Устанавливает frozen frontend lock-граф, runtime-компилирует все сообщения RU/EN реальным `vue-i18n`, проверяет типы и собирает production bundle. +13. Собирает backend HY2XS admin в Linux amd64 binary, проставляя версию через ldflags. +14. Прогоняет `go vet` и `go test` для HY2XS admin (после сборки frontend, потому что `go:embed all:dist` требует готовых ассетов). +15. Проверяет зависимости через `govulncheck ./...` и `pnpm audit` по всему lock-графу. +16. Записывает metadata и checksums. +17. Создаёт архив и прогоняет его acceptance-проверки. ## Контракт версий @@ -99,6 +100,10 @@ Go-коде больше нет: она уже успела разъехатьс только политика `HYSTERIA_CHANNEL`, результат резолва — в [`hysteria-lock.env`](hysteria-lock.env)). +Security overrides frontend находятся в `apps/frontend/pnpm-workspace.yaml`: +это канонический файл настроек pnpm. Поле `pnpm` в `package.json` для этой цели +не используется, потому что новые версии package manager его игнорируют. + ## Версия Hysteria: разрешение и compatibility gate Builder не хранит версию Hysteria вручную. По умолчанию он определяет последнюю стабильную версию сам и замораживает её в пакете. diff --git a/tools/build/build.sh b/tools/build/build.sh index 1fc5bfd..1b68f4b 100755 --- a/tools/build/build.sh +++ b/tools/build/build.sh @@ -43,9 +43,9 @@ main() { log_step "Checking orchestrator contracts" run_orchestrator_tests - # Контракты панели проверяются рано: они не требуют ни собранного bundle, ни - # установленных зависимостей, и падать на них после резолва Hysteria и сборки - # артефактов означало бы платить минутами за ошибку, видимую сразу. + # Dependency-free контракты панели проверяются рано. Runtime-компиляция + # словарей запускается внутри bundle_ui сразу после frozen install, потому + # что ей нужен реальный vue-i18n из frontend lock-графа. log_step "Checking HY2XS admin frontend contracts" run_frontend_tests diff --git a/tools/build/lib/acceptance.sh b/tools/build/lib/acceptance.sh index 0a5d6aa..a7f93d8 100644 --- a/tools/build/lib/acceptance.sh +++ b/tools/build/lib/acceptance.sh @@ -1345,14 +1345,20 @@ $piped_matcher" || fail "acceptance: прогон тестов оркестратора не фиксируется результатом" grep -q 'ADMIN_TESTS_PASSED' tools/build/lib/package.sh \ || fail "acceptance: прогон тестов админки не фиксируется результатом" + grep -q 'FRONTEND_CONTRACT_TESTS_PASSED' tools/build/lib/package.sh \ + || fail "acceptance: ранние контракты панели не фиксируются результатом" + grep -q 'FRONTEND_RUNTIME_TESTS_PASSED' tools/build/lib/package.sh \ + || fail "acceptance: runtime-компиляция локализации не фиксируется результатом" grep -q 'FRONTEND_TESTS_PASSED' tools/build/lib/package.sh \ - || fail "acceptance: прогон контрактных тестов панели не фиксируется результатом" + || fail "acceptance: общий результат тестов панели не фиксируется" "$BUN_BIN" -e ' const source = require("node:fs").readFileSync("tools/build/lib/package.sh", "utf8"); for (const [fn, flag] of [ ["run_orchestrator_tests()", "ORCHESTRATOR_TESTS_PASSED=\"true\""], ["run_admin_tests()", "ADMIN_TESTS_PASSED=\"true\""], - ["run_frontend_tests()", "FRONTEND_TESTS_PASSED=\"true\""] + ["run_frontend_tests()", "FRONTEND_CONTRACT_TESTS_PASSED=\"true\""], + ["bundle_ui()", "FRONTEND_RUNTIME_TESTS_PASSED=\"true\""], + ["bundle_ui()", "FRONTEND_TESTS_PASSED=\"true\""] ]) { const start = source.indexOf(fn); if (start < 0) throw new Error("не найдена функция " + fn); @@ -1366,6 +1372,19 @@ $piped_matcher" if (set < 0) throw new Error(fn + ": результат прогона не фиксируется"); if (set < run) throw new Error(fn + ": результат объявляется раньше, чем получен"); } + + const bundleStart = source.indexOf("bundle_ui()"); + const bundleRest = source.slice(bundleStart); + const bundleEnd = bundleRest.search(/\n\}[\r\n]/); + const bundle = bundleEnd < 0 ? bundleRest : bundleRest.slice(0, bundleEnd); + const install = bundle.indexOf("install --frozen-lockfile"); + const runtime = bundle.indexOf("test test/i18n-runtime.test.ts"); + const typecheck = bundle.indexOf("run typecheck"); + const build = bundle.indexOf("run build:prod"); + if (install < 0 || runtime < 0) throw new Error("runtime i18n gate не найден в bundle_ui"); + if (!(install < runtime && runtime < typecheck && typecheck < build)) { + throw new Error("порядок frontend gate обязан быть install -> i18n runtime -> typecheck -> build"); + } ' || fail "acceptance: утверждение о пройденных тестах обязано следовать за прогоном" # Детектор гонок — часть прогона админки, а не пожелание. diff --git a/tools/build/lib/package.sh b/tools/build/lib/package.sh index 005f55b..93baa6e 100644 --- a/tools/build/lib/package.sh +++ b/tools/build/lib/package.sh @@ -64,7 +64,7 @@ run_orchestrator_tests() { } run_frontend_tests() { - # Контракты панели, которые не проверяются ни типами, ни сборкой bundle: + # Ранние контракты панели, которые не требуют установленных зависимостей: # цвет иконок в спрайте, совпадение словарей локализации, соответствие кодов # ошибок серверным константам, единственность адреса атрибуции. # @@ -80,8 +80,8 @@ run_frontend_tests() { "$BUN_BIN" test tools/test/frontend-sprite.test.ts tools/test/frontend-contract.test.ts \ || fail "HY2XS admin frontend contract tests failed" - FRONTEND_TESTS_PASSED="true" - export FRONTEND_TESTS_PASSED + FRONTEND_CONTRACT_TESTS_PASSED="true" + export FRONTEND_CONTRACT_TESTS_PASSED } run_admin_tests() { @@ -146,6 +146,9 @@ build_orchestrator() { bundle_ui() { local ui_src="${UI_SRC:-apps}" + [ "${FRONTEND_CONTRACT_TESTS_PASSED:-false}" = "true" ] \ + || fail "bundle_ui: ранние контракты панели не проверялись" + rm -rf "$ADMIN_BUILD_DIR" mkdir -p "$ADMIN_BUILD_DIR" @@ -164,6 +167,12 @@ bundle_ui() { "$PNPM_BIN" install --frozen-lockfile + # Словари являются JavaScript-объектами и компилируются vue-i18n только + # при вызове t(). Поэтому ни vue-tsc, ни Vite не видят синтаксическую + # ошибку сообщения. Gate использует тот же установленный runtime, обходит + # все строки обоих языков и выполняется до typecheck и production bundle. + "$BUN_BIN" test test/i18n-runtime.test.ts + # Проверка типов идёт ПЕРЕД сборкой bundle, и это обязательный шаг релиза. # # Раньше её здесь не было вовсе: `build:prod` означал @@ -176,7 +185,7 @@ bundle_ui() { # кода, который не проходит проверку типов, нет. "$PNPM_BIN" run typecheck "$PNPM_BIN" run build:prod - ) + ) || fail "HY2XS admin frontend runtime tests or bundle failed" # Версия админки приходит из versions.env через ldflags: один и тот же # исполняемый файл обязан знать версию пакета, из которого он собран. @@ -196,6 +205,12 @@ bundle_ui() { mkdir -p "$STAGE_DIR/ui/hy2xs-admin/docs/sql" install -m 0644 "$ui_src/docs/sql/schema.sql" "$STAGE_DIR/ui/hy2xs-admin/docs/sql/schema.sql" fi + + # Оба утверждения выставляются только после dependency-free контрактов, + # runtime-компиляции словарей и успешной сборки production bundle. + FRONTEND_RUNTIME_TESTS_PASSED="true" + FRONTEND_TESTS_PASSED="true" + export FRONTEND_RUNTIME_TESTS_PASSED FRONTEND_TESTS_PASSED } write_metadata() { diff --git a/tools/test/frontend-contract.test.ts b/tools/test/frontend-contract.test.ts index d44eb03..d6530f4 100644 --- a/tools/test/frontend-contract.test.ts +++ b/tools/test/frontend-contract.test.ts @@ -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(/|>)/g)].map( - (match) => match[0] - ); + const columns = [ + ...viewer.matchAll(/|>)/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 [" { 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( + //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; + 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}"`); } }); });