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'')
+ asset = validate_asset(raw, "canonical.svg")
+ assert asset.data != raw
+ assert not asset.data.startswith(b"" not in asset.data, "пустой элемент сжимается"
+ assert asset.data == (
+ 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'")
+ 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 = (
+ ''
+ ).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)