diff --git a/demo/guard/README.md b/demo/guard/README.md index 69ecd26e..989ec97d 100644 --- a/demo/guard/README.md +++ b/demo/guard/README.md @@ -12,7 +12,18 @@ (`scripts/source-fingerprint.mjs`), то есть каждый новый `.mjs` там объявляет устаревшими закоммиченный бандл, скриншот-индекс документации и golden-индекс. Пробы гарда ни одного пикселя не касаются, платить пересъёмкой за них нечем. -3. **Каталог, а не файл** — проб три, и они читаются как набор. +3. **Каталог, а не файл** — проб несколько, и они читаются как набор. Запускает их `verify-guard.mjs`; он же вызывается из job «Смоки в браузере» -и служит guard'ом двух мутантов в `scripts/mutation-gate.mjs`. +и служит guard'ом мутантов в `scripts/mutation-gate.mjs`. + +Одна проба живёт не здесь: `--guard-probe` у `demo/benchmark_backdrop_decode.mjs` +(#430). Benchmark нельзя переселить в этот каталог — его запускают руками при +рекалибровке порогов, — поэтому `verify-guard.mjs` умеет запускать файл выше +каталога и с аргументами. Режим `--guard-probe` пропускает все замеры, бросает +одно исключение внутри страницы и обязан выйти с кодом 1. + +Новый benchmark, открывающий страницу Playwright, обязан получить такой же +режим и запись здесь: этого требует `test/smoke-harness-contract.test.mjs`, и +требует статически — потому что динамическое доказательство возможно только +там, где есть браузер. diff --git a/demo/guard/verify-guard.mjs b/demo/guard/verify-guard.mjs index 0e1c2509..fa3e83f8 100644 --- a/demo/guard/verify-guard.mjs +++ b/demo/guard/verify-guard.mjs @@ -18,7 +18,13 @@ import { fileURLToPath } from 'node:url'; const HERE = dirname(fileURLToPath(import.meta.url)); -/** Каждая проба: чего ждём от кода возврата и что обязано быть в выводе. */ +/** + * Каждая проба: чего ждём от кода возврата и что обязано быть в выводе. + * + * `args` — необязательные аргументы запуска; `file` может указывать и выше + * этого каталога (`../benchmark_*.mjs`), потому что benchmark в `demo/guard/` + * не переселить: его гоняют руками при рекалибровке порогов (#430). + */ const PROBES = [ { file: 'guard_tail_exception.mjs', @@ -38,6 +44,19 @@ const PROBES = [ expectOutput: /OK/, because: 'round-trip к закрытой странице не имеет права ронять вердикт', }, + { + // #430: до этой задачи гард benchmark доказывался тестом, который искал + // в тексте те самые подстроки, которые сам же и вырезал у мутанта, — + // доказано было, что регулярка не пуста. Режим `--guard-probe` в + // benchmark существовал с #423 и не вызывался ни одним прогоном; теперь + // вызывается здесь, в единственной job с настоящим браузером. + file: '../benchmark_backdrop_decode.mjs', + args: ['--guard-probe'], + expectExit: 1, + expectOutput: /uncaught exception\(s\) inside the card/, + because: 'benchmark открывает страницу Playwright и обязан выносить тот же вердикт,' + + ' что и смоки: исключение внутри карточки во время замера иначе не увидит никто', + }, { file: 'guard_report_page_errors.mjs', expectExit: 1, @@ -48,14 +67,14 @@ const PROBES = [ let failed = 0; for (const probe of PROBES) { - const run = spawnSync(process.execPath, [resolve(HERE, probe.file)], { + const run = spawnSync(process.execPath, [resolve(HERE, probe.file), ...probe.args || []], { encoding: 'utf8', cwd: resolve(HERE, '../..'), timeout: 90_000, }); const output = `${run.stdout || ''}${run.stderr || ''}`; const exitOk = run.status === probe.expectExit; const textOk = probe.expectOutput.test(output); if (exitOk && textOk) { - console.log(`ok ${probe.file} → exit ${run.status}`); + console.log(`ok ${[probe.file, ...probe.args || []].join(' ')} → exit ${run.status}`); continue; } failed += 1; diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index a2eb1e94..cf45480d 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -4683,6 +4683,106 @@ const MUTANT_DEFINITIONS = [ + " errorCode: '',", }], }, + { + id: 'decor-raster-full-decode-skipped', + guard: 'python3 -m pytest tests_backend/test_decor_assets.py -q -p no:cacheprovider', + because: 'a valid-looking PNG whose IDAT is not a zlib stream must be refused before it ' + + 'enters the authenticated store; header parsing answers w/h/mime and cannot answer ' + + 'whether the raster decodes at all (#51 AC, аудит #430 п.1)', + patches: [{ + file: 'custom_components/houseplan/decor_assets.py', + find: ' with Image.open(BytesIO(data)) as image:\n' + + ' image.load()\n' + + ' if image.size != (width, height):\n' + + ' raise DecorAssetError("invalid_image", "Image dimensions are inconsistent")\n' + + ' if getattr(image, "is_animated", False):\n' + + ' raise DecorAssetError("unsupported_image", "Animated images are unsupported")', + replace: ' _ = (Image, BytesIO)', + }], + }, + { + id: 'decor-svg-canonical-bytes-discarded', + guard: 'python3 -m pytest tests_backend/test_decor_assets.py -q -p no:cacheprovider', + because: 'the stored SVG must be the re-serialised canonical form, not the upload: keeping ' + + 'the original bytes silently reinstates whatever the parser dropped — prologue, ' + + 'comments, exotic spelling of the same tree (#51 ТЗ §3, аудит #430 п.2)', + patches: [{ + file: 'custom_components/houseplan/decor_assets.py', + find: 'return ValidatedAsset(canonical, "image/svg+xml"', + replace: 'return ValidatedAsset(data, "image/svg+xml"', + }], + }, + { + id: 'decor-svg-canonical-size-unchecked', + guard: 'python3 -m pytest tests_backend/test_decor_assets.py -q -p no:cacheprovider', + because: 'canonicalisation can grow the document fourfold by escaping text, so the 2 MiB ' + + 'limit must be re-applied to the canonical bytes: a 1.84 MiB upload otherwise lands ' + + 'as 7.35 MiB in the store (аудит #430 п.2)', + patches: [{ + file: 'custom_components/houseplan/decor_assets.py', + find: ' _check_size(canonical)\n', + replace: '', + }], + }, + { + id: 'decor-svg-external-url-guard-off', + guard: 'python3 -m pytest tests_backend/test_decor_assets.py -q -p no:cacheprovider', + because: 'javascript:, data:, http:, https: and // inside an allowed attribute of an allowed ' + + 'tag are caught by this rule alone; every "external" case of the original corpus was ' + + 'caught by tag or attribute allowlists instead (аудит #430 п.3)', + patches: [{ + file: 'custom_components/houseplan/decor_assets.py', + find: 'if any(token in low for token in ("javascript:", "data:", "http:", "https:", "//")):', + replace: 'if False:', + }], + }, + { + id: 'decor-image-flip-v-ignored', + guard: 'node --test test/decor-assets.test.mjs', + because: 'vertical flip is half of the image projection contract and had no witness of its ' + + 'own: the single #51 case set flip_h only, so dropping flip_v stayed green (#51 AC3, ' + + 'аудит #430 п.4)', + patches: [{ + file: 'src/decor-assets.ts', + find: '${shape.flip_v ? -1 : 1}', + replace: '1', + }], + }, + { + id: 'decor-image-opacity-ignored', + guard: 'node --test test/decor-assets.test.mjs', + because: 'the projection must carry the shape opacity; the only case asserted opacity 2 → 1, ' + + 'an expectation indistinguishable from hardcoding 1 (#51 AC4, аудит #430 п.4)', + patches: [{ + file: 'src/decor-assets.ts', + find: 'const opacity = clamp01(shape.opacity, 1);', + replace: 'const opacity = 1;', + }], + }, + { + id: 'decor-asset-id-shape-unchecked', + guard: 'node --test test/decor-assets.test.mjs', + because: 'the catalog row must prove its own asset_id shape: the malformed row of #51 kept ' + + 'the url of a real asset, so the url comparison caught it and the id regex could be ' + + 'deleted unnoticed (аудит #430 п.4)', + patches: [{ + file: 'src/decor-assets.ts', + find: " if (!DECOR_ASSET_ID_RE.test(String(row.asset_id || '')) || row.url !== expectedUrl", + replace: ' if (row.url !== expectedUrl', + }], + }, + { + id: 'benchmark-page-verdict-unwatched', + guard: 'node demo/guard/verify-guard.mjs', + because: 'the page benchmark of #423 must register its page with watchPage, and that must be ' + + 'proven by running it: the previous proof asked a regexp whether it still finds the ' + + 'substring the same test had just deleted (аудит #430 п.5)', + patches: [{ + file: 'demo/benchmark_backdrop_decode.mjs', + find: 'const page = watchPage(await (await browser.newContext()).newPage());', + replace: 'const page = await (await browser.newContext()).newPage();', + }], + }, ]; const mutationCardSource = readFileSync(join(repoRoot, 'src/houseplan-card.ts'), 'utf8'); diff --git a/test/decor-assets.test.mjs b/test/decor-assets.test.mjs index 34a36a81..8d6ffc2c 100644 --- a/test/decor-assets.test.mjs +++ b/test/decor-assets.test.mjs @@ -34,6 +34,33 @@ test('#51 full and static renderers share one fail-closed image projection', () assert.equal(projectDecorImage({ ...shape, w: 0 }, 1000, 500), null); }); +test('#430 проекция читает flip_v, а не только flip_h', () => { + // Единственный кейс #51 задавал flip_h: true и ничего не говорил про flip_v, + // поэтому `${shape.flip_v ? -1 : 1}` → `1` оставляло 7 pass. Вертикальное + // отражение — половина контракта AC3, и своего свидетеля у неё не было. + const shape = { id: 'image', kind: 'image', asset_id: id('d'), x: 0, y: 0, w: 1, h: 1 }; + const scaleOf = (extra) => projectDecorImage({ ...shape, ...extra }, 100, 100)[5] + .match(/scale\([^)]*\)/)[0]; + assert.equal(scaleOf({}), 'scale(1 1)'); + assert.equal(scaleOf({ flip_h: true }), 'scale(-1 1)'); + assert.equal(scaleOf({ flip_v: true }), 'scale(1 -1)'); + assert.equal(scaleOf({ flip_h: true, flip_v: true }), 'scale(-1 -1)'); +}); + +test('#430 проекция переносит opacity, а не подставляет единицу', () => { + // Кейс #51 задавал opacity: 2 и ждал 1 — ожидание, неотличимое от «opacity + // игнорируется»: мутант `const opacity = 1` проходил. Промежуточное значение + // отличает перенос от заглушки, крайности закрепляют clamp и fallback. + const shape = { id: 'image', kind: 'image', asset_id: id('d'), x: 0, y: 0, w: 1, h: 1 }; + const opacityOf = (opacity) => projectDecorImage({ ...shape, opacity }, 100, 100)[4]; + assert.equal(opacityOf(0.4), 0.4); + assert.equal(opacityOf(0), 0, 'полностью прозрачная картинка — законное состояние'); + assert.equal(opacityOf(-1), 0); + assert.equal(opacityOf(2), 1); + assert.equal(opacityOf(undefined), 1, 'нет значения — непрозрачная'); + assert.equal(opacityOf('nonsense'), 1); +}); + test('#51 resolve projection rejects malformed catalog rows', () => { const good = { asset_id: id('c'), name: 'safe.svg', mime: 'image/svg+xml', @@ -46,6 +73,21 @@ test('#51 resolve projection rejects malformed catalog rows', () => { assert.equal(adoptDecorAssets({ assets: [{ ...good, mime: 'image/png' }] }).size, 0); }); +test('#430 форма asset_id проверяется сама, а не через совпадение с url', () => { + // Строка `{ ...good, asset_id: 'bad' }` выше не свидетель формы id: у неё + // остаётся url настоящего ассета, и её отбивает сравнение url. Поэтому + // мутант `!DECOR_ASSET_ID_RE.test(...)` → `false` выживал. Здесь url + // согласован с плохим id — сработать может только сама проверка формы. + const row = (asset_id) => ({ + asset_id, name: 'safe.svg', mime: 'image/svg+xml', width: 20, height: 10, bytes: 100, + url: `/api/houseplan/content/assets/_/${asset_id}.svg`, + }); + assert.equal(adoptDecorAssets({ assets: [row('bad')] }).size, 0); + assert.equal(adoptDecorAssets({ assets: [row(`${id('a')}a`)] }).size, 0, 'длиннее 64'); + assert.equal(adoptDecorAssets({ assets: [row(id('a').replace('a', 'A'))] }).size, 0, 'не hex'); + assert.equal(adoptDecorAssets({ assets: [row(id('a'))] }).size, 1, 'корректный id проходит'); +}); + test('#51 a rotated image contributes its complete visible bounds to framing', () => { const item = decorBoxItem({ x: 0.1, y: 0.2, w: 0.2, h: 0.1, angle: 90 }); assert.ok(item); diff --git a/test/smoke-exception-guard.test.mjs b/test/smoke-exception-guard.test.mjs index dbe15f8b..5da66797 100644 --- a/test/smoke-exception-guard.test.mjs +++ b/test/smoke-exception-guard.test.mjs @@ -70,16 +70,24 @@ test('пробы вызываются в job с браузером и служа assert.match(smoke, /if: matrix\.shard == 1/, 'один раз, а не в каждом шарде'); const mutants = read('scripts/mutation-gate.mjs'); - for (const id of [ + // Правка #404 состоит из двух половин, и мутант на одну оставил бы другую + // недоказанной. Список ведётся руками, и это осознанно: счётчик обязан + // совпадать с ним, поэтому новый мутант на этих пробах нельзя добавить, не + // назвав его здесь (в #430 так добавился четвёртый — гард page-benchmark). + const guarded = [ 'smoke-guard-blind-to-tail', 'smoke-guard-forgets-to-register-pages', 'report-page-errors-skips-round-trip', - ]) { + 'benchmark-page-verdict-unwatched', + ]; + for (const id of guarded) { assert.match(mutants, new RegExp(`id: '${id}'`), `мутант ${id} не зарегистрирован`); } - // Правка состоит из двух половин, и мутант на одну оставил бы другую - // недоказанной. - assert.equal((mutants.match(/node demo\/guard\/verify-guard\.mjs/g) || []).length, 3); + assert.equal( + (mutants.match(/node demo\/guard\/verify-guard\.mjs/g) || []).length, + guarded.length, + 'мутант на пробах гарда есть, а в списке выше его нет — список отстал от реестра', + ); }); test('страницы, созданные вне launch(), подписаны общим гардом (#404, Medium-1)', () => { diff --git a/test/smoke-harness-contract.test.mjs b/test/smoke-harness-contract.test.mjs index 629c8876..f2dc381d 100644 --- a/test/smoke-harness-contract.test.mjs +++ b/test/smoke-harness-contract.test.mjs @@ -86,26 +86,39 @@ test('#421 dedicated guard probe reaches reportPageErrors without finish', () => 'finish() замаскирует сломанный round-trip внутри reportPageErrors()'); }); -test('#423 every Playwright page benchmark has an enforceable pageerror verdict', () => { +test('#423 каждый page-benchmark обязан иметь вердикт и динамическую пробу (#430)', () => { + // Что здесь проверяется, а что — нет. + // + // Этот тест — ОБНАРУЖЕНИЕ: он находит benchmark, открывающий страницу + // Playwright, и требует от него формы гарда и наличия отрицательной пробы. + // Поведение гарда в рантайме он не доказывает и не может: браузера в job + // «Фронтенд» нет. Доказывает запуск — `demo/guard/verify-guard.mjs`. + // + // До #430 здесь стояли две «отрицательные проверки»: результат + // `pageBenchmarkIsGuarded(source.replace('watchPage(', '('))` обязан быть + // false. Но сама функция буквально ищет подстроку `watchPage(` — вырезав её, + // мы спрашивали регулярку, находит ли она то, что мы только что удалили. + // Доказано было, что регулярка не пуста. Ровно тот вид проверки, против + // которого заведён мутационный гейт, и он же — единственная находка аудита + // v1.71.0-beta.1, где тест был циклическим, а не просто слабым. const benchmarks = readdirSync(DEMO) .filter((name) => name.startsWith('benchmark_') && name.endsWith('.mjs')) .filter((name) => /\.newPage\(/.test(read(name))); assert.deepEqual(benchmarks, ['benchmark_backdrop_decode.mjs']); + const verifier = readFileSync( + new URL('../demo/guard/verify-guard.mjs', import.meta.url), 'utf8', + ); for (const name of benchmarks) { const source = read(name); assert.equal(pageBenchmarkIsGuarded(source), true, `${name}: missing pageerror guard`); - assert.equal( - pageBenchmarkIsGuarded(source.replace('watchPage(', '(')), - false, - `${name}: removing watchPage must make the contract red`, - ); - assert.equal( - pageBenchmarkIsGuarded(source.replace('reportPageErrors()', 'false')), - false, - `${name}: removing the final verdict must make the contract red`, + assert.match(source, /process\.argv\.includes\('--guard-probe'\)/, + `${name}: нужен режим отрицательной пробы — без него гард не проверить запуском`); + assert.match(source, /setTimeout\(\(\) => \{ throw new Error\(/, + `${name}: проба обязана бросать исключение ВНУТРИ страницы, в хвосте замера`); + assert.ok( + new RegExp(`file: '\\.\\./${name}',\\s*\n\\s*args: \\['--guard-probe'\\]`).test(verifier), + `${name}: нет записи в demo/guard/verify-guard.mjs — проба существует и не вызывается,` + + ' а это ровно то состояние, в котором #423 прожил до #430', ); } - const backdrop = read('benchmark_backdrop_decode.mjs'); - assert.match(backdrop, /process\.argv\.includes\('--guard-probe'\)/); - assert.match(backdrop, /houseplan backdrop guard probe/); }); diff --git a/tests_backend/test_decor_assets.py b/tests_backend/test_decor_assets.py index c8067b55..c04fbe73 100644 --- a/tests_backend/test_decor_assets.py +++ b/tests_backend/test_decor_assets.py @@ -2,10 +2,14 @@ from __future__ import annotations import base64 +import importlib import json +import struct +import zlib import pytest +from custom_components.houseplan.const import MAX_DECOR_ASSET_BYTES from custom_components.houseplan.decor_assets import ( DecorAssetError, asset_meta_path, @@ -51,9 +55,11 @@ def test_declared_mime_must_agree_with_extension_and_bytes() -> None: (WEBP_VP8L_3X2, "lossless.webp", "image/webp"), (WEBP_VP8X_3X2, "alpha.webp", "image/webp"), ]) -def test_supported_raster_headers_and_full_decode( +def test_supported_raster_headers_and_dimensions( payload: bytes, filename: str, mime: str, ) -> None: + """Имя до #430 обещало «and_full_decode», а проверялись w/h/mime — их даёт + header-парсер, и мутация полного декодирования оставалась зелёной.""" asset = validate_asset(payload, filename, "application/octet-stream") assert (asset.width, asset.height, asset.mime) == (3, 2, mime) @@ -185,11 +191,121 @@ def test_svg_rejects_one_oversized_attribute_before_tree_use() -> None: validate_asset(payload, "bounded.svg") -def test_valid_looking_but_truncated_raster_is_rejected_by_full_decode() -> None: - with pytest.raises(DecorAssetError, match="corrupt|decode"): +def test_truncated_raster_is_rejected_by_the_header_parser() -> None: + """Обрезка на 33 байтах убирает `IEND`, поэтому файл отбивает парсер + заголовка. До #430 этот тест назывался «by_full_decode» и записывался в + доказательство декодирования — при no-op блока Pillow он оставался + зелёным. Настоящий свидетель декодирования — тест ниже.""" + with pytest.raises(DecorAssetError, match="corrupt or has the wrong type"): validate_asset(PNG_1X1[:33], "truncated.png", "image/png") +def _png_with_corrupt_idat() -> bytes: + """PNG, безупречный для парсера заголовка и мёртвый для декодера. + + Сигнатура, IHDR с честными 1x1, IEND на месте, длины и CRC всех чанков + верны — придраться нечему, пока кто-нибудь не попробует распаковать IDAT, + в котором лежит не zlib-поток, а текст. + """ + def chunk(kind: bytes, payload: bytes) -> bytes: + return (struct.pack(">I", len(payload)) + kind + payload + + struct.pack(">I", zlib.crc32(kind + payload) & 0xFFFFFFFF)) + + return (b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", struct.pack(">IIBBBBB", 1, 1, 8, 0, 0, 0, 0)) + + chunk(b"IDAT", b"not a zlib stream at all") + + chunk(b"IEND", b"")) + + +def test_valid_looking_png_is_rejected_by_the_full_pillow_decode() -> None: + """Свидетель полного декодирования (#430, п.1 аудита v1.71.0-beta.1). + + Раньше блок `with Image.open(...) as image: image.load()` можно было + заменить на no-op, и все 35 тестов файла оставались зелёными: заголовок + отвечал на все вопросы, которые они задавали. Здесь спрашивается то, на + что заголовок ответить не может, — распаковывается ли растр вообще. + """ + pytest.importorskip("PIL", reason="полное декодирование делает Pillow") + with pytest.raises(DecorAssetError, match="cannot be decoded"): + validate_asset(_png_with_corrupt_idat(), "corrupt.png", "image/png") + + +def test_pillow_is_present_wherever_home_assistant_is() -> None: + """Свидетель выше не имеет права молча пропускаться в каноне. + + `validate_asset` глотает `ImportError` осознанно: чистое подмножество + тестов должно работать без всего HA. Цена — пропуск теста там, где Pillow + нет, а пропущенный тест выглядит как пройденный. Поэтому в окружении с + Home Assistant (Linux CI, WSL) отсутствие Pillow — красное само по себе. + """ + pytest.importorskip("homeassistant", reason="чистая песочница без HA") + importlib.import_module("PIL.Image") + + +def test_svg_asset_carries_canonical_bytes_not_the_upload() -> None: + """Свидетель канонизации (#430, п.2). ТЗ §3 требует ре-сериализацию, а + `ValidatedAsset(canonical, …)` → `ValidatedAsset(data, …)` не краснило ни + один тест: все проверяли w/h/mime и ни один — сами байты. + """ + raw = (b'\n' + b'\n' + b'' + b'' + b'a>b' + b'') + asset = validate_asset(raw, "canonical.svg") + assert asset.data != raw + assert not asset.data.startswith(b"" not in asset.data, "пустой элемент сжимается" + assert asset.data == ( + b'' + b'' + b'a>b' + b'' + ) + + +def test_svg_size_limit_applies_to_canonical_bytes() -> None: + """Свидетель второй половины п.2: `_check_size(canonical)`. + + Экранирование `>` в тексте раздувает документ вчетверо, поэтому загрузка + размером 1.84 МиБ канонизуется в 7.35 МиБ. Проверка размера на входе это + пропускает; снятие `_check_size(canonical)` до #430 не краснило ничего, и + в хранилище уезжал файл вчетверо больше объявленного предела. + """ + payload = (b'' + + (b"" + b">" * 4096 + b"") * 470 + + b"") + assert len(payload) < MAX_DECOR_ASSET_BYTES, "загрузка обязана проходить входной контроль" + with pytest.raises(DecorAssetError, match="2 MiB"): + validate_asset(payload, "canonical-bomb.svg") + + +@pytest.mark.parametrize("value", [ + "javascript:alert(1)", + "data:image/png;base64,AAAA", + "http://example.com/x", + "https://example.com/x", + "//example.com/x", +]) +def test_svg_external_resource_guard_catches_what_no_other_rule_does(value: str) -> None: + """Свидетель гарда внешних URL (#430, п.3). + + Все три «внешних» кейса корпуса ловились другими правилами: тегом не из + словаря, атрибутом не из словаря, ветвью `name == "href"`. Замена условия + на `if False:` не краснила ничего. Здесь тег и атрибут разрешённые, `url(` + нет, `href` нет — сработать может только сам токен-гард, и сообщение это + подтверждает. + """ + payload = ( + '' + f'' + ).encode() + with pytest.raises(DecorAssetError, match="External SVG resources are forbidden"): + validate_asset(payload, "external.svg") + + def test_catalog_ignores_missing_or_malformed_sidecars(tmp_path) -> None: aid = "a" * 64 (tmp_path / f"{aid}.png").write_bytes(PNG_1X1)