mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 04:09:17 +00:00
Аудит v1.71.0-beta.1 (§3.2 M1/M2/M3/M6/M9) прогнал по мутанту на каждый контракт: пять снятий защиты не покраснили ни один тест. Восьмой подряд случай проверок, не умеющих падать, и первый — в бете, закрывавшей #421, задачу ровно об этом. Каждый свидетель ниже проверен отрицательным прогоном: мутант краснит ровно свой тест и не задевает остальные. 1. Полное декодирование растра. `test_supported_raster_headers_and_full_decode` спрашивал w/h/mime — их даёт header-парсер; обрезка `PNG_1X1[:33]` теряет IEND и отбивается там же. No-op блока Pillow оставлял 35 pass. Новый свидетель: PNG с верными сигнатурой, IHDR, IEND, длинами и CRC, но с текстом вместо zlib-потока в IDAT. Оба прежних теста переименованы — их имена обещали то, чего они не проверяли. Пропуск без Pillow остался (validate_asset глотает ImportError осознанно), но в окружении с Home Assistant отсутствие Pillow теперь красное само по себе — иначе свидетель молча скипался бы в каноне. 2. Канонизация SVG. `ValidatedAsset(canonical,…)` → `ValidatedAsset(data,…)` не краснило ничего: все тесты смотрели w/h/mime, ни один — байты. Свидетель сверяет байты целиком: пролог и комментарий не переживают канонизацию, пустой элемент сжимается. Вторая половина — `_check_size(canonical)`. Экранирование `>` в тексте раздувает документ вчетверо: загрузка 1.84 МиБ канонизуется в 7.35 МиБ и до сих пор проходила входной контроль. Свидетель — ровно такая. 3. Гард внешних URL. Все три «внешних» кейса корпуса ловились другими правилами (тег не из словаря, атрибут не из словаря, ветвь href), и `if False:` не краснило ничего. Свидетель: разрешённый тег, разрешённый атрибут, пять токенов — сработать может только сам гард, сообщение это подтверждает. 4. Проекция декора. Единственный кейс задавал flip_h и opacity: 2 → 1; ожидание неотличимо от «opacity игнорируется», а flip_v не проверялся вовсе. Свидетели: четыре комбинации отражений, opacity 0.4 и 0 против заглушки, форма asset_id с согласованным url (прежняя строка ловилась сравнением url, поэтому регулярку id можно было удалить незаметно). 5. Гард benchmark из #423. Доказательство было циклическим: тест вырезал из текста подстроку и спрашивал регулярку, находит ли она её, — а регулярка искала именно её. Динамический режим `--guard-probe` существовал с #423 и не вызывался ни одним прогоном. Теперь он в `demo/guard/verify-guard.mjs` (умеет аргументы и файл выше каталога), а тест из обнаружения требует у нового page-benchmark режим пробы и запись в верификаторе. Мутантов в реестре стало восемь новых: четыре бэкендных, три юнитных, один на пробах гарда. У #423 их было ноль — единственная задача с циклическим тестом и она же единственная без мутантов. Гейты: typecheck зелёный; npm test 1790 tests, 1789 pass, 0 fail; pytest без HA 310 passed, 2 skipped; npm run build зелёный, dist не изменился (продуктовый код не тронут); mutation-gate --check применяет все восемь якорей. Полный прогон новых мутантов — следующим шагом. Issue: #430 User-Visible: no
This commit is contained in:
+13
-2
@@ -12,7 +12,18 @@
|
|||||||
(`scripts/source-fingerprint.mjs`), то есть каждый новый `.mjs` там объявляет
|
(`scripts/source-fingerprint.mjs`), то есть каждый новый `.mjs` там объявляет
|
||||||
устаревшими закоммиченный бандл, скриншот-индекс документации и golden-индекс.
|
устаревшими закоммиченный бандл, скриншот-индекс документации и golden-индекс.
|
||||||
Пробы гарда ни одного пикселя не касаются, платить пересъёмкой за них нечем.
|
Пробы гарда ни одного пикселя не касаются, платить пересъёмкой за них нечем.
|
||||||
3. **Каталог, а не файл** — проб три, и они читаются как набор.
|
3. **Каталог, а не файл** — проб несколько, и они читаются как набор.
|
||||||
|
|
||||||
Запускает их `verify-guard.mjs`; он же вызывается из job «Смоки в браузере»
|
Запускает их `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`, и
|
||||||
|
требует статически — потому что динамическое доказательство возможно только
|
||||||
|
там, где есть браузер.
|
||||||
|
|||||||
@@ -18,7 +18,13 @@ import { fileURLToPath } from 'node:url';
|
|||||||
|
|
||||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
/** Каждая проба: чего ждём от кода возврата и что обязано быть в выводе. */
|
/**
|
||||||
|
* Каждая проба: чего ждём от кода возврата и что обязано быть в выводе.
|
||||||
|
*
|
||||||
|
* `args` — необязательные аргументы запуска; `file` может указывать и выше
|
||||||
|
* этого каталога (`../benchmark_*.mjs`), потому что benchmark в `demo/guard/`
|
||||||
|
* не переселить: его гоняют руками при рекалибровке порогов (#430).
|
||||||
|
*/
|
||||||
const PROBES = [
|
const PROBES = [
|
||||||
{
|
{
|
||||||
file: 'guard_tail_exception.mjs',
|
file: 'guard_tail_exception.mjs',
|
||||||
@@ -38,6 +44,19 @@ const PROBES = [
|
|||||||
expectOutput: /OK/,
|
expectOutput: /OK/,
|
||||||
because: 'round-trip к закрытой странице не имеет права ронять вердикт',
|
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',
|
file: 'guard_report_page_errors.mjs',
|
||||||
expectExit: 1,
|
expectExit: 1,
|
||||||
@@ -48,14 +67,14 @@ const PROBES = [
|
|||||||
|
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
for (const probe of PROBES) {
|
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,
|
encoding: 'utf8', cwd: resolve(HERE, '../..'), timeout: 90_000,
|
||||||
});
|
});
|
||||||
const output = `${run.stdout || ''}${run.stderr || ''}`;
|
const output = `${run.stdout || ''}${run.stderr || ''}`;
|
||||||
const exitOk = run.status === probe.expectExit;
|
const exitOk = run.status === probe.expectExit;
|
||||||
const textOk = probe.expectOutput.test(output);
|
const textOk = probe.expectOutput.test(output);
|
||||||
if (exitOk && textOk) {
|
if (exitOk && textOk) {
|
||||||
console.log(`ok ${probe.file} → exit ${run.status}`);
|
console.log(`ok ${[probe.file, ...probe.args || []].join(' ')} → exit ${run.status}`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
failed += 1;
|
failed += 1;
|
||||||
|
|||||||
@@ -4683,6 +4683,106 @@ const MUTANT_DEFINITIONS = [
|
|||||||
+ " errorCode: '',",
|
+ " 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');
|
const mutationCardSource = readFileSync(join(repoRoot, 'src/houseplan-card.ts'), 'utf8');
|
||||||
|
|||||||
@@ -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);
|
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', () => {
|
test('#51 resolve projection rejects malformed catalog rows', () => {
|
||||||
const good = {
|
const good = {
|
||||||
asset_id: id('c'), name: 'safe.svg', mime: 'image/svg+xml',
|
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);
|
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', () => {
|
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 });
|
const item = decorBoxItem({ x: 0.1, y: 0.2, w: 0.2, h: 0.1, angle: 90 });
|
||||||
assert.ok(item);
|
assert.ok(item);
|
||||||
|
|||||||
@@ -70,16 +70,24 @@ test('пробы вызываются в job с браузером и служа
|
|||||||
assert.match(smoke, /if: matrix\.shard == 1/, 'один раз, а не в каждом шарде');
|
assert.match(smoke, /if: matrix\.shard == 1/, 'один раз, а не в каждом шарде');
|
||||||
|
|
||||||
const mutants = read('scripts/mutation-gate.mjs');
|
const mutants = read('scripts/mutation-gate.mjs');
|
||||||
for (const id of [
|
// Правка #404 состоит из двух половин, и мутант на одну оставил бы другую
|
||||||
|
// недоказанной. Список ведётся руками, и это осознанно: счётчик обязан
|
||||||
|
// совпадать с ним, поэтому новый мутант на этих пробах нельзя добавить, не
|
||||||
|
// назвав его здесь (в #430 так добавился четвёртый — гард page-benchmark).
|
||||||
|
const guarded = [
|
||||||
'smoke-guard-blind-to-tail',
|
'smoke-guard-blind-to-tail',
|
||||||
'smoke-guard-forgets-to-register-pages',
|
'smoke-guard-forgets-to-register-pages',
|
||||||
'report-page-errors-skips-round-trip',
|
'report-page-errors-skips-round-trip',
|
||||||
]) {
|
'benchmark-page-verdict-unwatched',
|
||||||
|
];
|
||||||
|
for (const id of guarded) {
|
||||||
assert.match(mutants, new RegExp(`id: '${id}'`), `мутант ${id} не зарегистрирован`);
|
assert.match(mutants, new RegExp(`id: '${id}'`), `мутант ${id} не зарегистрирован`);
|
||||||
}
|
}
|
||||||
// Правка состоит из двух половин, и мутант на одну оставил бы другую
|
assert.equal(
|
||||||
// недоказанной.
|
(mutants.match(/node demo\/guard\/verify-guard\.mjs/g) || []).length,
|
||||||
assert.equal((mutants.match(/node demo\/guard\/verify-guard\.mjs/g) || []).length, 3);
|
guarded.length,
|
||||||
|
'мутант на пробах гарда есть, а в списке выше его нет — список отстал от реестра',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('страницы, созданные вне launch(), подписаны общим гардом (#404, Medium-1)', () => {
|
test('страницы, созданные вне launch(), подписаны общим гардом (#404, Medium-1)', () => {
|
||||||
|
|||||||
@@ -86,26 +86,39 @@ test('#421 dedicated guard probe reaches reportPageErrors without finish', () =>
|
|||||||
'finish() замаскирует сломанный round-trip внутри reportPageErrors()');
|
'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)
|
const benchmarks = readdirSync(DEMO)
|
||||||
.filter((name) => name.startsWith('benchmark_') && name.endsWith('.mjs'))
|
.filter((name) => name.startsWith('benchmark_') && name.endsWith('.mjs'))
|
||||||
.filter((name) => /\.newPage\(/.test(read(name)));
|
.filter((name) => /\.newPage\(/.test(read(name)));
|
||||||
assert.deepEqual(benchmarks, ['benchmark_backdrop_decode.mjs']);
|
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) {
|
for (const name of benchmarks) {
|
||||||
const source = read(name);
|
const source = read(name);
|
||||||
assert.equal(pageBenchmarkIsGuarded(source), true, `${name}: missing pageerror guard`);
|
assert.equal(pageBenchmarkIsGuarded(source), true, `${name}: missing pageerror guard`);
|
||||||
assert.equal(
|
assert.match(source, /process\.argv\.includes\('--guard-probe'\)/,
|
||||||
pageBenchmarkIsGuarded(source.replace('watchPage(', '(')),
|
`${name}: нужен режим отрицательной пробы — без него гард не проверить запуском`);
|
||||||
false,
|
assert.match(source, /setTimeout\(\(\) => \{ throw new Error\(/,
|
||||||
`${name}: removing watchPage must make the contract red`,
|
`${name}: проба обязана бросать исключение ВНУТРИ страницы, в хвосте замера`);
|
||||||
);
|
assert.ok(
|
||||||
assert.equal(
|
new RegExp(`file: '\\.\\./${name}',\\s*\n\\s*args: \\['--guard-probe'\\]`).test(verifier),
|
||||||
pageBenchmarkIsGuarded(source.replace('reportPageErrors()', 'false')),
|
`${name}: нет записи в demo/guard/verify-guard.mjs — проба существует и не вызывается,`
|
||||||
false,
|
+ ' а это ровно то состояние, в котором #423 прожил до #430',
|
||||||
`${name}: removing the final verdict must make the contract red`,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const backdrop = read('benchmark_backdrop_decode.mjs');
|
|
||||||
assert.match(backdrop, /process\.argv\.includes\('--guard-probe'\)/);
|
|
||||||
assert.match(backdrop, /houseplan backdrop guard probe/);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,10 +2,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
import importlib
|
||||||
import json
|
import json
|
||||||
|
import struct
|
||||||
|
import zlib
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from custom_components.houseplan.const import MAX_DECOR_ASSET_BYTES
|
||||||
from custom_components.houseplan.decor_assets import (
|
from custom_components.houseplan.decor_assets import (
|
||||||
DecorAssetError,
|
DecorAssetError,
|
||||||
asset_meta_path,
|
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_VP8L_3X2, "lossless.webp", "image/webp"),
|
||||||
(WEBP_VP8X_3X2, "alpha.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,
|
payload: bytes, filename: str, mime: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Имя до #430 обещало «and_full_decode», а проверялись w/h/mime — их даёт
|
||||||
|
header-парсер, и мутация полного декодирования оставалась зелёной."""
|
||||||
asset = validate_asset(payload, filename, "application/octet-stream")
|
asset = validate_asset(payload, filename, "application/octet-stream")
|
||||||
assert (asset.width, asset.height, asset.mime) == (3, 2, mime)
|
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")
|
validate_asset(payload, "bounded.svg")
|
||||||
|
|
||||||
|
|
||||||
def test_valid_looking_but_truncated_raster_is_rejected_by_full_decode() -> None:
|
def test_truncated_raster_is_rejected_by_the_header_parser() -> None:
|
||||||
with pytest.raises(DecorAssetError, match="corrupt|decode"):
|
"""Обрезка на 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")
|
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'<?xml version="1.0" encoding="utf-8"?>\n'
|
||||||
|
b'<!-- pipeline comment -->\n'
|
||||||
|
b'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 20" >'
|
||||||
|
b'<rect x="0" y="0" width="1" height="1"></rect>'
|
||||||
|
b'<title>a>b</title>'
|
||||||
|
b'</svg>')
|
||||||
|
asset = validate_asset(raw, "canonical.svg")
|
||||||
|
assert asset.data != raw
|
||||||
|
assert not asset.data.startswith(b"<?xml"), "XML-пролог не переживает канонизацию"
|
||||||
|
assert b"<!--" not in asset.data, "комментарий не переживает канонизацию"
|
||||||
|
assert b"<rect " in asset.data and b"</rect>" not in asset.data, "пустой элемент сжимается"
|
||||||
|
assert asset.data == (
|
||||||
|
b'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 20">'
|
||||||
|
b'<rect x="0" y="0" width="1" height="1" />'
|
||||||
|
b'<title>a>b</title>'
|
||||||
|
b'</svg>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_svg_size_limit_applies_to_canonical_bytes() -> None:
|
||||||
|
"""Свидетель второй половины п.2: `_check_size(canonical)`.
|
||||||
|
|
||||||
|
Экранирование `>` в тексте раздувает документ вчетверо, поэтому загрузка
|
||||||
|
размером 1.84 МиБ канонизуется в 7.35 МиБ. Проверка размера на входе это
|
||||||
|
пропускает; снятие `_check_size(canonical)` до #430 не краснило ничего, и
|
||||||
|
в хранилище уезжал файл вчетверо больше объявленного предела.
|
||||||
|
"""
|
||||||
|
payload = (b'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 20">'
|
||||||
|
+ (b"<desc>" + b">" * 4096 + b"</desc>") * 470
|
||||||
|
+ b"</svg>")
|
||||||
|
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 = (
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 20">'
|
||||||
|
f'<path fill="{value}" d="M0 0L1 1"/></svg>'
|
||||||
|
).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:
|
def test_catalog_ignores_missing_or_malformed_sidecars(tmp_path) -> None:
|
||||||
aid = "a" * 64
|
aid = "a" * 64
|
||||||
(tmp_path / f"{aid}.png").write_bytes(PNG_1X1)
|
(tmp_path / f"{aid}.png").write_bytes(PNG_1X1)
|
||||||
|
|||||||
Reference in New Issue
Block a user