[test-coverage] Тесты для security-рефактора html-embed (commit 81823fce): sandbox + trackerHead
#98
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Контекст
Область — изменения коммита
81823fce(«feat(html-embed): sandbox the embed block; split trusted trackers into an admin field»): html-embed переведён в песочницу-iframe (sandbox="allow-scripts allow-popups allow-forms", opaque-origin,srcdoc), удалена вся ролевая обвязка стрипа, добавлено admin-only полеsettings.trackerHead(инъекция в<head>только страниц публичного шаринга).Полный анализ (8 модулей, по одному read-only субагенту на модуль, покрытие проверено инструментом) — в
docs/test-strategy-report.md. Этот issue агрегирует все предложенные тесты в один список.Проверено coverage-инструментом: «чистое ядро»
html-embed.util.tsпокрыто на 100 %, но все новые security-поверхности коммита не покрыты вовсе — инъекцияtrackerHead(0 %), валидация DTO (0 %), клиентские sandbox/postMessage/clamp (0 %), гейтинг slash-меню (0 %), атрибутheight(0 %), CASL-гейт записиtrackerHead(0 %).Дефекты, которые должны быть закрыты тестами
trackerHeadвставляется НЕ дословно ($&-hazard).apps/server/src/core/share/share-seo.controller.ts:99-102:transformedHtml.replace('</head>', \{trackerHead}\n</head>\`)`. `String.prototype.replace` со строковым заменителем трактует спецшаблоны `,&, `` $, `$'`, `$n`. Так как `trackerHead` подставлен В строку-заменитель, сниппет с `$&` молча заменяется на `</head>`, а``/'вставят весь окружающий документ — комментарий «injected verbatim» неверен. Вход admin-only, но это реальная тихая порча. **Fix:** заменитель-функция() => `${trackerHead}\n</head>`(функция не интерпретирует-шаблоны) либо экранирование→$$`.heightвозвращаетNaNна мусоре.packages/editor-ext/src/lib/html-embed/html-embed.ts:98-100: голыйparseInt(data-height)без guard'аisNaN(соседнийdrawio.ts:105-109защищён).NaN-высота утекает в PM-JSON и ломает resize в NodeView. Fix: добавить тот жеisNaN(...)?null.Тесты — Фаза 1 (security-граница, рефакторинг НЕ нужен; максимальный ROI)
trackerHeadв ShareSeoController — спекаshare-seo.controller.spec.tsотсутствует. Не покрыты ветки нового кода: guardtrim().length > 0(пропуск пустого/пробельного), сам факт вставки до</head>, и буквальность вставки. Контроллер строить с моками, застабитьfs.existsSync→true иfs.readFileSync→фикст��рныйindex.html. Сценарии: сниппет вставлен перед</head>; пустой/undefined/whitespace → html без изменений; буквальный round-trip сниппета с$&(вскрывает BUG-1); нет share → инъекции НЕ происходит (даже еслиtrackerHeadнастроен); нет workspace → инъекции нет; wrong-workspace отбрасываетсяgetShareForPage. (layer: unit дляinjectTrackerHeadпосле R-s1 + integration для контроллера; ловит: BUG-1, утечку трекера на не-share-ответ, кросс-workspace утечку.) [HIGH]trackerHead—WorkspaceController.updateWorkspaceс ability роли MEMBER →ForbiddenException,workspaceService.updateНЕ вызывается; OWNER/ADMIN → вызывается. (integration, моки; ловит: запись trackerHead не-админом — единственный гейт на контроллереworkspace.controller.ts:90-95.) [HIGH]UpdateWorkspaceDto(class-validator, черезplainToInstance+validate):trackerHead— валидная строка ок, ровно 20000 ок, 20001 →maxLength, не-строка →isString;htmlEmbed— true/false ок, не-boolean ("true",1) →isBoolean(с учётомtransform:trueглобального pipe —"true"НЕ коэрсится, проверить отказ). (unit; ловит: проход oversized/неверного типа до verbatim-инъекции. Существующийworkspace-html-embed.spec.tsобходит валидацию черезas any.) [HIGH]getSuggestionItems(menu-items.ts:815): пункт «HTML embed» виден при тоггле ON, скрыт при OFF/отсутствии ключа (default OFF), битый JSON в localStorage → скрыт без исключения. (unit, рефактор не нужен; ловит: показ пункта при выключенной фиче.) [HIGH]stripHtmlEmbedNodes/hasHtmlEmbedNode(html-embed.util.ts): несколько embed-сиблингов и в разных ветвях; deep-clone vs shared reference;contentне-массив / пустой[]; embed только в глубоком потомке (3+ уровня). (unit; ловит: фильтр, останавливающийся после первого совпадения, и aliasing входа.) [MED]unsyncReferenceсохраняет html-embed —transclusion.service.tsс мок-репозиториями (замена удалённогоtransclusion-unsync-html-embed.spec.ts, инвертированная на «сохраняет»). (integration, рефактор не нужен; ловит: повторное появление стрипа на unsync.) [MED]Тесты — Фаза 2 (требуется вынос чистых функций)
message) —html-embed-view.tsx:72-91(guardevent.source !== iframeRef.current?.contentWindowL75, guard типа сообщения, отказ при!Number.isFiniteL79, clamp по обеим границам L81 и L90, short-circuit при фиксированной высоте). Проект тестирует компоненты через@testing-library/react, но дешевле вынести чистыйclampHeight+ предикат приёма сообщения вrender-raw-html.ts(рядом с уже юнит-тестируемымиshouldExecute/canEdit). Тестировать границы клэмпа и guard поevent.source. (unit после R-c2/R-c3; ловит: приём resize-сообщений ��з чужих фреймов, NaN в layout, DoS-раздувание.) [HIGH]buildEmbedIframePropsизhtml-embed-view.tsx:152: sandbox ровноallow-scripts allow-popups allow-forms, нетallow-same-origin, заданsrcDoc,srcотсутствует. (unit после R-c1; ловит: ослабление песочницы → выход в origin/XSS.) [HIGH]heightв editor-ext —html-embed-codec.spec.tsпроверяет только base64-source. Вынестиparse/renderHtmlEmbedHeight(html-embed.ts:96-104) и протестировать:data-height="300"→300; отсутствует→null; число→data-height; falsy-skip (null/0) против truthy-emit в renderHTML; round-trip render→parse (0→null lossy, NaN→null — вскрывает BUG-2). Editor-ext импортируется и клиентом, и сервером — расхождение parse/serialize молча уронит/исказит высоту. (unit + 1 contract на потерю height в markdown, после R-e1.) [MED]applyHtmlEmbedToWorkspace/applyTrackerHeadToWorkspace(html-embed-settings.tsx,tracker-settings.tsx): force-set значения даже если ответ его опускает, сохранение пустой строки, неперезатирание соседнихsettings-ключей. (unit после R-cs1/R-cs2.) [MED]TrackerSettingsдля не-админа — textarea И Save кнопкаdisabled(поле остаётся в DOM by design — проверятьdisabled, не отсутствие). (component/integration, рефактор не нужен; ловит: UX-утечку привилегии. Реальная граница — на сервере.) [MED]Тесты — Фаза 3 (требуется инфраструктура тестов)
PageService.create/duplicatePageсохраняют html-embed — нужен R-p1:PageServiceне грузится в jest из-за ESM-зависимости@sindresorhus/slugifyвнеtransformIgnorePatterns(apps/server/package.json:208). (integration после R-p1.) [MED]page.controller→PageService.createровно с 4 аргументами — нужен R-p2:page.controller.spec.ts/page.service.spec.tsисключены из CI и являются заглушкамиtoBeDefined(). УдалённыйcallerRoleиprovenanceбыли трейлинг-опциональными → устаревший вызов сuser.roleсдвинул бы аргумент в слотprovenance(тихий баг). (integration после un-exclude.) [MED]@vitest/coverage-v8для измеримого порога покрытия клиента/editor-ext (сейчас не установлен).Необходимые рефакторинги (prerequisites)
injectTrackerHead(html, trackerHead)изshare-seo.controller.ts:97-103(заодно закрыть BUG-1).buildEmbedIframeProps/isTrustedHeightMessage/clampIframeHeightизhtml-embed-view.tsx.parse/renderHtmlEmbedHeightизhtml-embed.ts:96-104(заодно BUG-2).PageServiceзагружаемым в jest (@sindresorhus/slugifyвtransformIgnorePatternsлибо вынос вывода контента в чистый модуль).page.controller.spec.ts/page.service.spec.tsиз exclude-листа и переписать вместо заглушек.Антипаттерны (выявлено)
page.service.spec.ts,page.controller.spec.ts,workspace.service.spec.ts— вjest.testPathIgnorePatternsИ лишьexpect(...).toBeDefined()→ ложное ощущение покрытия.workspace-html-embed.spec.tsзовётservice.update('w1', {...} as any), минуя DTO/ValidationPipe→ «безопасность trackerHead» там не проверяется.НЕ тестировать (skip-list)
DI/конструкторы, hocuspocus/yjs/BullMQ/Kysely-обвязка, pass-through пути записи (уже покрыты ниже по пирамиде через
html-embed-import-detect.spec.ts),sendIndex/резолв workspace по host, виджеты Mantine, типы, строки локализации, SQL-телоupdateSetting(нужен реальный Postgres — отложено).Ghost referenced this issue2026-06-21 04:19:31 +03:00