From f4b425f3c0baf4f326a30669cc23a5f63c4bbaad Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Wed, 2 Sep 2026 20:13:58 +0300 Subject: [PATCH] test: prove three defensive contracts fail red Issue: #421 User-Visible: no --- custom_components/houseplan/websocket_api.py | 9 +- demo/guard/guard_report_page_errors.mjs | 14 ++++ demo/guard/verify-guard.mjs | 6 ++ scripts/docs-accept.mjs | 35 +++++--- scripts/mutation-gate.mjs | 69 ++++++++++++++++ test/docs-accept.test.mjs | 48 ++++++++++- test/smoke-exception-guard.test.mjs | 15 +++- test/smoke-harness-contract.test.mjs | 10 +++ tests_backend/test_ha_websocket.py | 86 ++++++++++++++++++-- 9 files changed, 269 insertions(+), 23 deletions(-) create mode 100644 demo/guard/guard_report_page_errors.mjs diff --git a/custom_components/houseplan/websocket_api.py b/custom_components/houseplan/websocket_api.py index fe3965a5..661c49c6 100755 --- a/custom_components/houseplan/websocket_api.py +++ b/custom_components/houseplan/websocket_api.py @@ -234,8 +234,13 @@ def _send_support_error(connection, msg_id: int, code: str) -> None: connection.send_error(msg_id, code, code) +def _support_monotonic() -> float: + """Clock seam for deterministic support-preview lifecycle tests.""" + return time.monotonic() + + def _prune_support_previews(rt: HouseplanData, now: float | None = None) -> None: - current = time.monotonic() if now is None else now + current = _support_monotonic() if now is None else now for token, preview in list(rt.support_previews.items()): if float(preview.get("expires", 0)) <= current: rt.support_previews.pop(token, None) @@ -2183,7 +2188,7 @@ async def ws_support_preview( _send_support_error(connection, msg["id"], code) return - now = time.monotonic() + now = _support_monotonic() owner = _connection_user_id(connection) _prune_support_previews(rt, now) # A refresh replaces only this card instance's draft. Other cards keep diff --git a/demo/guard/guard_report_page_errors.mjs b/demo/guard/guard_report_page_errors.mjs new file mode 100644 index 00000000..89599644 --- /dev/null +++ b/demo/guard/guard_report_page_errors.mjs @@ -0,0 +1,14 @@ +// Probe #421: reportPageErrors() owns a separate delivery round-trip from finish(). +// +// The page error is scheduled at the tail of the browser turn and there is no +// page interaction afterwards. The verdict must therefore flush the page by +// itself. This probe deliberately never calls finish(): doing so would prove +// the older branch while leaving reportPageErrors() blind. +import { launch, reportPageErrors } from '../serve.mjs'; + +const { browser, page } = await launch(); +await page.evaluate(() => { + setTimeout(() => { throw new Error('guard-report-page-errors'); }, 0); +}); +await reportPageErrors(); +await browser.close(); diff --git a/demo/guard/verify-guard.mjs b/demo/guard/verify-guard.mjs index 7d679ab5..0e1c2509 100644 --- a/demo/guard/verify-guard.mjs +++ b/demo/guard/verify-guard.mjs @@ -38,6 +38,12 @@ const PROBES = [ expectOutput: /OK/, because: 'round-trip к закрытой странице не имеет права ронять вердикт', }, + { + file: 'guard_report_page_errors.mjs', + expectExit: 1, + expectOutput: /uncaught exception\(s\) inside the card/, + because: 'reportPageErrors() обязан сам дождаться хвостового pageerror, не полагаясь на finish()', + }, ]; let failed = 0; diff --git a/scripts/docs-accept.mjs b/scripts/docs-accept.mjs index dcf707d7..8cbf48d3 100644 --- a/scripts/docs-accept.mjs +++ b/scripts/docs-accept.mjs @@ -91,6 +91,23 @@ export function verifyDocsCandidate({ return { manifest, files }; } +/** Build the manifest written by the CLI without erasing an earlier review trace. */ +export function acceptedDocsManifest({ + manifest, previousAcceptance, decision, skipWitnesses = false, skipReason = '', +}) { + return { + ...manifest, + acceptance: decision.replace.length + ? { + declared: [...decision.replace], + witnesses: decision.witnesses.length, + floor: decision.floor, + ...(skipWitnesses ? { witnessesSkippedBecause: skipReason } : {}), + } + : { ...(previousAcceptance || {}), lastWriteWasFingerprintOnly: true }, + }; +} + const list = (argv, name) => argv .filter((arg) => arg.startsWith(`--${name}=`)) .map((arg) => arg.slice(name.length + 3)) @@ -145,17 +162,13 @@ function main(argv) { // пустым списком, и история терялась при первом же refresh. const previous = JSON.parse(readFileSync(resolve(ROOT, 'docs/images/screenshots.json'), 'utf8')) .acceptance; - const accepted = { - ...manifest, - acceptance: decision.replace.length - ? { - declared: [...decision.replace], - witnesses: decision.witnesses.length, - floor: decision.floor, - ...(skipWitnesses ? { witnessesSkippedBecause: skipReason } : {}), - } - : { ...(previous || {}), lastWriteWasFingerprintOnly: true }, - }; + const accepted = acceptedDocsManifest({ + manifest, + previousAcceptance: previous, + decision, + skipWitnesses, + skipReason, + }); writeFileSync( resolve(ROOT, 'docs/images/screenshots.json'), `${JSON.stringify(accepted, null, 2)}\n`, diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index ac68882d..1f57a038 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -139,6 +139,75 @@ const MUTANT_DEFINITIONS = [ replace: '', }], }, + { + id: 'support-preview-replacement-keeps-old-token', + guard: 'node scripts/backend-test-guard.mjs ' + + 'support_preview_replacement_and_discard_are_draft_local ' + + 'tests_backend/test_ha_websocket.py', + because: 'refreshing one support draft must invalidate its previous token while leaving ' + + 'another draft usable; discard success alone cannot prove either fact (#421 AC1)', + patches: [{ + file: 'custom_components/houseplan/websocket_api.py', + find: ' for old_token, record in list(rt.support_previews.items()):\n' + + ' if record.get("owner") == owner and record.get("draft_id") == msg["draft_id"]:\n' + + ' rt.support_previews.pop(old_token, None)\n', + replace: '', + }], + }, + { + id: 'support-preview-discard-keeps-token', + guard: 'node scripts/backend-test-guard.mjs ' + + 'support_preview_replacement_and_discard_are_draft_local ' + + 'tests_backend/test_ha_websocket.py', + because: 'an idempotent discard response is not proof of invalidation; a later submit must ' + + 'fail because the owned token was actually removed (#421 AC2)', + patches: [{ + file: 'custom_components/houseplan/websocket_api.py', + find: ' rt.support_previews.pop(msg["token"], None)\n' + + ' connection.send_result(msg["id"], {"ok": True})', + replace: ' connection.send_result(msg["id"], {"ok": True})', + }], + }, + { + id: 'support-preview-submit-skips-ttl-prune', + guard: 'node scripts/backend-test-guard.mjs ' + + 'support_preview_token_expires_at_ttl_without_transport ' + + 'tests_backend/test_ha_websocket.py', + because: 'the exact TTL boundary must reject an expired attachment before relay transport; ' + + 'without submit-time pruning the stale bytes remain usable (#421 AC3)', + patches: [{ + file: 'custom_components/houseplan/websocket_api.py', + find: ' _prune_support_previews(rt)\n' + + ' token = msg.get("preview_token")', + replace: ' token = msg.get("preview_token")', + }], + }, + { + id: 'report-page-errors-skips-round-trip', + guard: 'node demo/guard/verify-guard.mjs', + because: 'reportPageErrors() is a verdict path separate from finish(); removing only its ' + + 'delivery round-trip must leave the dedicated tail-error probe visibly red (#421 AC4/AC5)', + patches: [{ + file: 'demo/serve.mjs', + find: 'export async function reportPageErrors() {\n' + + ' await roundTripLivePages();\n' + + ' if (!_pageErrors) return false;', + replace: 'export async function reportPageErrors() {\n' + + ' if (!_pageErrors) return false;', + }], + }, + { + id: 'docs-fingerprint-refresh-erases-acceptance-trace', + guard: 'node --test --test-name-pattern="fingerprint-only refresh" ' + + 'test/docs-accept.test.mjs', + because: 'a source-fingerprint refresh changes no pixels and must preserve the earlier ' + + 'human acceptance trace instead of manufacturing an empty one (#421 AC6/AC7)', + patches: [{ + file: 'scripts/docs-accept.mjs', + find: ' : { ...(previousAcceptance || {}), lastWriteWasFingerprintOnly: true },', + replace: ' : { declared: [], witnesses: 0, floor: 0 },', + }], + }, { id: 'i18n-dead-key-returns', guard: 'node --test test/i18n-dead-keys.test.mjs', diff --git a/test/docs-accept.test.mjs b/test/docs-accept.test.mjs index 07a50481..7a9b26dc 100644 --- a/test/docs-accept.test.mjs +++ b/test/docs-accept.test.mjs @@ -3,7 +3,7 @@ import test from 'node:test'; import { createHash } from 'node:crypto'; import { readFileSync } from 'node:fs'; -import { verifyDocsCandidate } from '../scripts/docs-accept.mjs'; +import { acceptedDocsManifest, verifyDocsCandidate } from '../scripts/docs-accept.mjs'; import { DOC_SCREENSHOT_VERSION, DOC_SCREENSHOTS } from '../demo/docs/screenshots.mjs'; // Приёмка — единственное место, где картинки попадают в репозиторий, поэтому @@ -146,3 +146,49 @@ test('правило среды в шапках совпадает с реали assert.equal(/снимать только в CI|только из артефакта CI/.test(workflow), false, 'старое правило про место съёмки осталось в тексте'); }); + +test('#421 acceptance trace records a new pixel review exactly', () => { + const manifest = candidate(); + const decision = { + replace: ['view-desktop'], witnesses: ['view-touch', 'editor'], floor: 2, + }; + const accepted = acceptedDocsManifest({ + manifest, + previousAcceptance: { declared: ['old'], future: 'preserve only on refresh' }, + decision, + skipWitnesses: true, + skipReason: 'reviewed renderer transition', + }); + assert.deepEqual(accepted.acceptance, { + declared: ['view-desktop'], + witnesses: 2, + floor: 2, + witnessesSkippedBecause: 'reviewed renderer transition', + }); + assert.equal('acceptance' in manifest, false, 'candidate manifest is not mutated'); + assert.notEqual(accepted, manifest); +}); + +test('#421 fingerprint-only refresh preserves the complete previous acceptance trace', () => { + const manifest = candidate(); + const previousAcceptance = { + declared: ['view-desktop'], witnesses: 7, floor: 1, future: { kept: true }, + }; + const previousSnapshot = structuredClone(previousAcceptance); + const accepted = acceptedDocsManifest({ + manifest, + previousAcceptance, + decision: { replace: [], witnesses: ['unchanged'], floor: 1 }, + }); + assert.deepEqual(accepted.acceptance, { + ...previousSnapshot, lastWriteWasFingerprintOnly: true, + }); + assert.deepEqual(previousAcceptance, previousSnapshot, 'previous trace is not mutated'); + assert.equal('acceptance' in manifest, false, 'candidate manifest is not mutated'); + + const firstRefresh = acceptedDocsManifest({ + manifest, previousAcceptance: undefined, + decision: { replace: [], witnesses: [], floor: 0 }, + }); + assert.deepEqual(firstRefresh.acceptance, { lastWriteWasFingerprintOnly: true }); +}); diff --git a/test/smoke-exception-guard.test.mjs b/test/smoke-exception-guard.test.mjs index ccf368aa..dbe15f8b 100644 --- a/test/smoke-exception-guard.test.mjs +++ b/test/smoke-exception-guard.test.mjs @@ -39,7 +39,12 @@ test('пробы гарда лежат вне маски смоков и вне const guard = readdirSync(new URL('demo/guard/', `file://${ROOT}`)); assert.ok(guard.includes('verify-guard.mjs')); const probes = guard.filter((name) => name.startsWith('guard_')); - assert.equal(probes.length, 3, 'три пробы: хвостовое исключение, отклонение, закрытая страница'); + assert.deepEqual(probes.sort(), [ + 'guard_closed_page.mjs', + 'guard_report_page_errors.mjs', + 'guard_tail_exception.mjs', + 'guard_tail_rejection.mjs', + ], 'четыре адресные пробы гарда обязаны оставаться обнаружимыми'); // Маска demo/smoke_*.mjs — то, что гоняют шарды CI. Проба, попавшая туда, // покрасит шард по построению: она обязана падать. @@ -65,12 +70,16 @@ test('пробы вызываются в job с браузером и служа assert.match(smoke, /if: matrix\.shard == 1/, 'один раз, а не в каждом шарде'); const mutants = read('scripts/mutation-gate.mjs'); - for (const id of ['smoke-guard-blind-to-tail', 'smoke-guard-forgets-to-register-pages']) { + for (const id of [ + 'smoke-guard-blind-to-tail', + 'smoke-guard-forgets-to-register-pages', + 'report-page-errors-skips-round-trip', + ]) { assert.match(mutants, new RegExp(`id: '${id}'`), `мутант ${id} не зарегистрирован`); } // Правка состоит из двух половин, и мутант на одну оставил бы другую // недоказанной. - assert.equal((mutants.match(/node demo\/guard\/verify-guard\.mjs/g) || []).length, 2); + assert.equal((mutants.match(/node demo\/guard\/verify-guard\.mjs/g) || []).length, 3); }); test('страницы, созданные вне launch(), подписаны общим гардом (#404, Medium-1)', () => { diff --git a/test/smoke-harness-contract.test.mjs b/test/smoke-harness-contract.test.mjs index 99657930..fee7215e 100644 --- a/test/smoke-harness-contract.test.mjs +++ b/test/smoke-harness-contract.test.mjs @@ -71,3 +71,13 @@ test('serve.mjs остаётся единственным владельцем const entryStale = read('smoke_entry_stale.mjs'); assert.match(entryStale, /await finish\(/, 'smoke_entry_stale обязан выносить вердикт'); }); + +test('#421 dedicated guard probe reaches reportPageErrors without finish', () => { + const probe = readFileSync( + new URL('../demo/guard/guard_report_page_errors.mjs', import.meta.url), 'utf8', + ); + assert.match(probe, /await reportPageErrors\(\)/, + 'отрицательная проба обязана пройти через отдельный verdict path'); + assert.doesNotMatch(probe, /\bawait\s+finish\s*\(/, + 'finish() замаскирует сломанный round-trip внутри reportPageErrors()'); +}); diff --git a/tests_backend/test_ha_websocket.py b/tests_backend/test_ha_websocket.py index 931875a8..9747080b 100644 --- a/tests_backend/test_ha_websocket.py +++ b/tests_backend/test_ha_websocket.py @@ -16,7 +16,12 @@ from homeassistant.core import HomeAssistant from pytest_homeassistant_custom_component.common import MockConfigEntry from pytest_homeassistant_custom_component.typing import WebSocketGenerator -from custom_components.houseplan.const import CONF_ADMIN_ONLY, DOMAIN, VERSION +from custom_components.houseplan.const import ( + CONF_ADMIN_ONLY, + DOMAIN, + SUPPORT_PREVIEW_TTL_S, + VERSION, +) from custom_components.houseplan.websocket_api import ( _space_delete_candidate, _space_marker_dependencies, ) @@ -1278,6 +1283,16 @@ def _support_preview_request(draft_id: str = "draft-browser-one") -> dict: } +async def _support_submit_request(client, token: str, idempotency_key: str) -> dict: + await client.send_json_auto_id({ + "type": "houseplan/support/submit", + "message": "Support preview lifecycle proof", + "preview_token": token, + "idempotency_key": idempotency_key, + }) + return await client.receive_json() + + async def test_support_preview_is_authorized_exact_and_consumed_only_after_success( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -1327,8 +1342,18 @@ async def test_support_preview_is_authorized_exact_and_consumed_only_after_succe async def test_support_preview_replacement_and_discard_are_draft_local( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + monkeypatch: pytest.MonkeyPatch, ) -> None: await _setup(hass) + submitted: list[dict] = [] + + async def _submit(_hass, **kwargs): + submitted.append(kwargs) + return "hpr-preview-lifecycle" + + monkeypatch.setattr( + "custom_components.houseplan.websocket_api.async_submit_report", _submit, + ) client = await hass_ws_client(hass) await client.send_json_auto_id(_support_preview_request("draft-same-card")) first = (await client.receive_json())["result"] @@ -1338,7 +1363,18 @@ async def test_support_preview_replacement_and_discard_are_draft_local( replacement = (await client.receive_json())["result"] assert len({first["token"], other["token"], replacement["token"]}) == 3 - # Replaced/discarded tokens cannot be submitted; another card's token stays. + # Replacement invalidates only the older token from the same card draft. + replaced = await _support_submit_request(client, first["token"], "replaced-token-proof") + assert not replaced["success"] + assert replaced["error"]["code"] == "support_preview_expired" + + other_sent = await _support_submit_request(client, other["token"], "other-draft-proof") + assert other_sent["success"] + assert other_sent["result"]["report_id"] == "hpr-preview-lifecycle" + assert len(submitted) == 1 + assert submitted[0]["attachment"] == other["text"].encode("utf-8") + + # Discard stays idempotent, but its effect is proved by a subsequent submit. await client.send_json_auto_id({ "type": "houseplan/support/preview/discard", "token": first["token"], }) @@ -1347,10 +1383,48 @@ async def test_support_preview_replacement_and_discard_are_draft_local( "type": "houseplan/support/preview/discard", "token": replacement["token"], }) assert (await client.receive_json())["success"] - await client.send_json_auto_id({ - "type": "houseplan/support/preview/discard", "token": other["token"], - }) - assert (await client.receive_json())["success"] + discarded = await _support_submit_request(client, replacement["token"], "discarded-token-proof") + assert not discarded["success"] + assert discarded["error"]["code"] == "support_preview_expired" + assert len(submitted) == 1 + + +async def test_support_preview_token_expires_at_ttl_without_transport( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + monkeypatch: pytest.MonkeyPatch, +) -> None: + await _setup(hass) + clock = {"now": 1_000.0} + monkeypatch.setattr( + "custom_components.houseplan.websocket_api._support_monotonic", + lambda: clock["now"], + ) + submitted: list[dict] = [] + + async def _submit(_hass, **kwargs): + submitted.append(kwargs) + return "hpr-preview-ttl" + + monkeypatch.setattr( + "custom_components.houseplan.websocket_api.async_submit_report", _submit, + ) + client = await hass_ws_client(hass) + + await client.send_json_auto_id(_support_preview_request("draft-before-ttl")) + fresh = (await client.receive_json())["result"] + clock["now"] += SUPPORT_PREVIEW_TTL_S - 1 + before_ttl = await _support_submit_request(client, fresh["token"], "before-ttl-proof") + assert before_ttl["success"] + assert submitted[0]["attachment"] == fresh["text"].encode("utf-8") + + await client.send_json_auto_id(_support_preview_request("draft-at-ttl")) + expiring = (await client.receive_json())["result"] + clock["now"] += SUPPORT_PREVIEW_TTL_S + at_ttl = await _support_submit_request(client, expiring["token"], "at-ttl-proof") + assert not at_ttl["success"] + assert at_ttl["error"]["code"] == "support_preview_expired" + assert len(submitted) == 1 async def test_support_text_only_submit_carries_safe_versions_without_plan_data(