diff --git a/.eslintrc.cjs b/.eslintrc.cjs index ed448eb4..93276b7d 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -30,6 +30,15 @@ module.exports = { 'import/no-extraneous-dependencies': 'off', // Test deps (vitest, etc.) are devDependencies 'sort-keys': 'off' // Test objects are ordered for readability, not alphabetically } + }, + { + files: ['pbt/**/*.ts'], + parserOptions: { + project: ['./pbt/tsconfig.json'] + }, + rules: { + 'import/no-extraneous-dependencies': 'off' // bombadil is a devDependency + } } ], parser: '@typescript-eslint/parser', diff --git a/.github/workflows/bombadil.yml b/.github/workflows/bombadil.yml new file mode 100644 index 00000000..b8cc9f0e --- /dev/null +++ b/.github/workflows/bombadil.yml @@ -0,0 +1,37 @@ +--- +name: Bombadil property tests + +# GHA requires bare `on:` which yamllint sees as boolean +# yamllint disable-line rule:truthy +on: + schedule: + - cron: "17 8 * * *" + workflow_dispatch: + +permissions: + contents: read + +jobs: + bombadil: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + - name: Set up Node + uses: actions/setup-node@v7 + with: + node-version: lts/jod + cache: yarn + - name: Install dependencies + run: yarn install --frozen-lockfile + - name: Run Bombadil exploration + env: + BOMBADIL_HEADLESS: "1" + BOMBADIL_TIME_LIMIT: 5m + run: yarn test:pbt + - name: Upload trace on violation + if: failure() + uses: actions/upload-artifact@v4 + with: + name: bombadil-output + path: pbt-output/ diff --git a/.github/workflows/python-checks.yml b/.github/workflows/python-checks.yml index 0166c64e..b301300b 100644 --- a/.github/workflows/python-checks.yml +++ b/.github/workflows/python-checks.yml @@ -91,6 +91,8 @@ jobs: run: uv pip install --system -r requirements_dev.txt pytest-cov pytest-github-actions-annotate-failures - name: Run tests and generate coverage report run: pytest ./tests/ --cov=custom_components/lock_code_manager/ --cov-report=xml --junitxml=junit.xml + env: + HYPOTHESIS_PROFILE: ci - name: Upload coverage to Codecov if: matrix.python-version == needs.setup.outputs.target-python uses: codecov/codecov-action@v7 diff --git a/.gitignore b/.gitignore index 68a8a915..568fc1df 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ venv .codex .coverage coverage/ +.hypothesis/ .pytest_cache .mypy_cache .ruff_cache @@ -22,3 +23,4 @@ docs/ .github/hooks/ .opencode/ junit.xml +pbt-output/ diff --git a/AGENTS.md b/AGENTS.md index 254f7ce6..9ed9b407 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -316,6 +316,29 @@ tests/providers/ - Provider `async_setup` must be idempotent — tests verify it can be called multiple times. - Don't use `MagicMock()` for coordinators when the real coordinator can be used. +## Property-based testing + +Two complementary layers exist alongside the example-based suites: + +- **Python (Hypothesis)** — `tests/properties/` runs as part of the normal + `pytest tests/` invocation. The default `dev` profile uses 15 examples to + keep the suite fast; CI sets `HYPOTHESIS_PROFILE=ci` (200 examples). Run + the deep profile locally with `HYPOTHESIS_PROFILE=ci pytest tests/properties/`. + `test_credential_machine.py` is a stateful machine driving the real + BaseLock write orchestration against `MockLCMLock`. +- **TypeScript (Bombadil)** — `pbt/` contains a browser harness that mounts + the built cards with a scripted mock `hass` plus a chaos panel, and a + Bombadil spec (`pbt/spec.ts`) with temporal-logic properties (no PIN leaks + from masked cards, chip counts match the model, pushed data eventually + renders). Run locally with `yarn test:pbt` (env knobs: + `BOMBADIL_TIME_LIMIT`, `BOMBADIL_HEADLESS=1`). CI runs it nightly and on + manual dispatch (`bombadil.yml`), never on pull requests. Inspect + violations with `yarn bombadil browser inspect pbt-output`. + +Counterexample triage: a found counterexample is a deliverable. Either fix +the code, or — only for documented contract edges — narrow the strategy +with a comment citing the contract. Never silence one by rerunning. + ## Adding Lock Provider Support 1. Create new file in `providers/` (e.g., `my_provider.py`) diff --git a/custom_components/lock_code_manager/providers/_util.py b/custom_components/lock_code_manager/providers/_util.py index e63e0167..c97f2d29 100644 --- a/custom_components/lock_code_manager/providers/_util.py +++ b/custom_components/lock_code_manager/providers/_util.py @@ -98,7 +98,8 @@ def parse_slot_num(value: object) -> int | None: Convert a slot identifier to an int, or return None if not convertible. Mirrors ``int(value)`` while collapsing the ``TypeError``/``ValueError`` - that providers otherwise catch when a lock reports a non-numeric slot key. + that providers otherwise catch when a lock reports a non-numeric slot key + (and ``OverflowError``, which ``int`` raises for infinite floats). JSON booleans are rejected rather than coerced (``int(True)`` is 1, so a malformed ``true`` would otherwise silently address slot 1). Call sites remain responsible for their own logging and skip/return flow. @@ -107,5 +108,5 @@ def parse_slot_num(value: object) -> int | None: return None try: return int(value) # type: ignore[call-overload] - except TypeError, ValueError: + except TypeError, ValueError, OverflowError: return None diff --git a/package.json b/package.json index 142b3737..c5c24a35 100644 --- a/package.json +++ b/package.json @@ -14,11 +14,12 @@ "scripts": { "build": "yarn install ; rollup -c", "watch": "rollup -c --watch", - "lint": "eslint --ext .ts ./ts", + "lint": "eslint --ext .ts ./ts ./pbt", "lint:fix": "yarn lint --fix", "test": "vitest run", "test:watch": "vitest", - "test:coverage": "vitest run --coverage" + "test:coverage": "vitest run --coverage", + "test:pbt": "yarn build && node pbt/run.mjs" }, "dependencies": { "@mdi/js": "^7.4.47", @@ -27,6 +28,7 @@ "lit-html": "^3.3.3" }, "devDependencies": { + "@antithesishq/bombadil": "0.6.1", "@babel/core": "^7.29.7", "@babel/preset-env": "^7.29.7", "@rollup/plugin-babel": "^7.1.0", diff --git a/pbt/harness/harness.js b/pbt/harness/harness.js new file mode 100644 index 00000000..2f35f67d --- /dev/null +++ b/pbt/harness/harness.js @@ -0,0 +1,222 @@ +/** + * Bombadil harness: mounts lcm-slot and lcm-lock-codes with a scripted mock + * hass. Plain JavaScript on purpose — no build step; served statically next + * to the built bundle. The model below is the single source of truth; chaos + * buttons mutate it and re-push through the subscription callbacks, the same + * path the real websocket API uses. + */ + +const CONFIG_ENTRY_ID = 'pbt_entry'; +const LOCK_ENTITY_ID = 'lock.pbt_front_door'; + +const model = { + revision: 0, + suspended: false, + slots: { + 1: { active: true, enabled: true, inSync: true, name: 'Alice', pin: '4921' }, + 2: { active: false, enabled: false, inSync: true, name: 'Bob', pin: '8375' }, + 3: { active: true, enabled: true, inSync: false, name: 'Carol', pin: '6104' } + } +}; + +// Read-only window surface for spec extractors. Secret PINs deliberately +// live here (JavaScript only), never in the DOM: the no-leak property greps +// the rendered DOM for them. +window.__lcmHarness = { + model, + secretPins: () => Object.values(model.slots).map((slot) => slot.pin) +}; + +/** Subscriptions keyed by an opaque id -> {message, callback}. */ +const subscriptions = new Map(); +let nextSubscriptionId = 1; + +function slotCardData(slotNum) { + const slot = model.slots[slotNum]; + return { + active: slot.active, + conditions: {}, + config_entry_id: CONFIG_ENTRY_ID, + config_entry_title: 'PBT Lock Manager', + enabled: slot.enabled, + entities: { + active: `binary_sensor.slot_${slotNum}_active`, + enabled: `switch.slot_${slotNum}_enabled`, + name: `text.slot_${slotNum}_name`, + pin: `text.slot_${slotNum}_pin` + }, + locks: [ + { + code: null, + code_length: slot.pin.length, + entity_id: LOCK_ENTITY_ID, + in_sync: slot.inSync, + name: 'Front Door', + sync_status: slot.inSync ? 'in_sync' : 'out_of_sync' + } + ], + name: slot.name, + // Harness cards run code_display: 'masked' — the PIN value never + // leaves the model; only its length does. + pin: null, + pin_length: slot.pin.length, + slot_num: slotNum + }; +} + +function lockCoordinatorData() { + return { + lock_entity_id: LOCK_ENTITY_ID, + lock_name: 'Front Door', + ...(model.suspended ? { sync_status: 'suspended' } : {}), + slots: Object.entries(model.slots).map(([slotNum, slot]) => ({ + active: slot.active, + code: null, + code_length: slot.pin.length, + config_entry_id: CONFIG_ENTRY_ID, + config_entry_title: 'PBT Lock Manager', + in_sync: slot.inSync, + // ts/types.ts LockCoordinatorSlotData names this `managed` + // (lock-codes-card.ts reads `slot.managed` throughout); the + // original draft used `is_managed`, which the card would have + // silently ignored. + managed: true, + name: slot.name, + slot: Number(slotNum) + })) + }; +} + +function payloadFor(message) { + if (message.type === 'lock_code_manager/subscribe_code_slot') { + return model.slots[message.slot] ? slotCardData(message.slot) : null; + } + if (message.type === 'lock_code_manager/subscribe_lock_codes') { + return lockCoordinatorData(); + } + return null; +} + +function pushAll() { + model.revision += 1; + for (const { callback, message } of subscriptions.values()) { + const payload = payloadFor(message); + if (payload !== null) { + callback(payload); + } + } +} + +const mockHass = { + callService: (domain, service, data) => { + // Card-initiated edits loop back through the model like the real + // backend would. + pushAll(); + return Promise.resolve({ domain, data, service }); + }, + callWS: () => Promise.resolve({}), + connection: { + subscribeMessage: (callback, message) => { + const id = nextSubscriptionId; + nextSubscriptionId += 1; + subscriptions.set(id, { callback, message }); + const payload = payloadFor(message); + if (payload !== null) { + callback(payload); + } + return Promise.resolve(() => subscriptions.delete(id)); + } + }, + states: { + [LOCK_ENTITY_ID]: { + attributes: { friendly_name: 'Front Door' }, + entity_id: LOCK_ENTITY_ID, + state: 'locked' + } + } +}; + +function mountCards() { + for (const slotNum of Object.keys(model.slots)) { + const card = document.createElement('lcm-slot'); + card.setConfig({ + code_display: 'masked', + config_entry_id: CONFIG_ENTRY_ID, + slot: Number(slotNum), + type: 'custom:lcm-slot' + }); + card.hass = mockHass; + document.getElementById('masked-zone').appendChild(card); + } + const lockCodes = document.createElement('lcm-lock-codes'); + lockCodes.setConfig({ + code_display: 'masked', + lock_entity_id: LOCK_ENTITY_ID, + type: 'custom:lcm-lock-codes' + }); + lockCodes.hass = mockHass; + document.getElementById('lock-codes-zone').appendChild(lockCodes); +} + +function randomPin() { + return String(Math.floor(1000 + Math.random() * 9000)); +} + +function randomSlotNum() { + const nums = Object.keys(model.slots); + return Number(nums[Math.floor(Math.random() * nums.length)]); +} + +const chaosHandlers = { + 'chaos-add-slot': () => { + const next = [1, 2, 3, 4, 5, 6].find((n) => !model.slots[n]); + if (next) { + model.slots[next] = { + active: true, + enabled: true, + inSync: false, + name: `User ${next}`, + pin: randomPin() + }; + } + }, + 'chaos-clear-slot': () => { + const nums = Object.keys(model.slots); + if (nums.length > 1) { + delete model.slots[randomSlotNum()]; + } + }, + 'chaos-external-pin': () => { + model.slots[randomSlotNum()].pin = randomPin(); + }, + 'chaos-rename': () => { + const slot = model.slots[randomSlotNum()]; + // Digit-free label: a numeric revision could collide with a live + // secret PIN and falsely trip the no-leak property. + slot.name = `Renamed ${String(model.revision).replace(/\d/g, (d) => 'ABCDEFGHIJ'[Number(d)])}`; + }, + 'chaos-toggle-active': () => { + const slot = model.slots[randomSlotNum()]; + slot.active = !slot.active; + }, + 'chaos-toggle-enabled': () => { + const slot = model.slots[randomSlotNum()]; + slot.enabled = !slot.enabled; + }, + 'chaos-toggle-suspended': () => { + model.suspended = !model.suspended; + }, + 'chaos-toggle-sync': () => { + const slot = model.slots[randomSlotNum()]; + slot.inSync = !slot.inSync; + } +}; + +for (const [id, handler] of Object.entries(chaosHandlers)) { + document.getElementById(id).addEventListener('click', () => { + handler(); + pushAll(); + }); +} + +mountCards(); diff --git a/pbt/harness/index.html b/pbt/harness/index.html new file mode 100644 index 00000000..e98d72c6 --- /dev/null +++ b/pbt/harness/index.html @@ -0,0 +1,47 @@ + + + + + LCM Bombadil Harness + + + + +
+ + + + + + + + +
+
+
+ + + + diff --git a/pbt/run.mjs b/pbt/run.mjs new file mode 100644 index 00000000..33d317fd --- /dev/null +++ b/pbt/run.mjs @@ -0,0 +1,43 @@ +/** + * Orchestrates a Bombadil run: start the static server, explore, clean up. + * Env knobs: BOMBADIL_TIME_LIMIT (default 60s), BOMBADIL_HEADLESS=1, + * PBT_PORT (default 8199). + * Exit code follows bombadil: 0 = clean, 2 = property violation. + */ +import { spawn } from 'node:child_process'; +import { setTimeout as sleep } from 'node:timers/promises'; + +const PORT = Number(process.env.PBT_PORT ?? 8199); +const TIME_LIMIT = process.env.BOMBADIL_TIME_LIMIT ?? '60s'; +const HEADLESS = process.env.BOMBADIL_HEADLESS === '1' || process.env.CI === 'true'; + +const server = spawn('node', ['pbt/serve.mjs'], { stdio: 'inherit' }); +await sleep(500); + +const args = [ + 'browser', + 'test', + `http://127.0.0.1:${PORT}/pbt/harness/`, + 'pbt/spec.ts', + `--time-limit=${TIME_LIMIT}`, + '--exit-on-violation', + '--output-path=pbt-output', + '--output-path-overwrite' +]; +if (HEADLESS) { + args.push('--headless'); +} +if (process.env.CI === 'true') { + args.push('--no-sandbox'); +} + +const bombadil = spawn('node_modules/.bin/bombadil', args, { stdio: 'inherit' }); +const exitCode = await new Promise((resolve) => { + bombadil.on('error', (err) => { + console.error('failed to launch bombadil:', err); + resolve(1); + }); + bombadil.on('exit', (code) => resolve(code ?? 1)); +}); +server.kill(); +process.exit(exitCode); diff --git a/pbt/serve.mjs b/pbt/serve.mjs new file mode 100644 index 00000000..c6e628c7 --- /dev/null +++ b/pbt/serve.mjs @@ -0,0 +1,50 @@ +/** Minimal static file server for the Bombadil harness (no dependencies). */ +import { createServer } from 'node:http'; +import { readFile } from 'node:fs/promises'; +import { extname, join, normalize } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = fileURLToPath(new URL('..', import.meta.url)); +const PORT = Number(process.env.PBT_PORT ?? 8199); +const MIME = { + '.css': 'text/css', + '.html': 'text/html', + '.js': 'text/javascript', + '.json': 'application/json', + '.mjs': 'text/javascript' +}; + +const server = createServer(async (req, res) => { + const url = new URL(req.url, `http://127.0.0.1:${PORT}`); + if (url.pathname === '/favicon.ico') { + res.writeHead(204); + res.end(); + return; + } + let path = normalize(url.pathname).replace(/^(\.\.[/\\])+/, ''); + if (path.endsWith('/')) { + path = `${path}index.html`; + } + // The cards navigate via history.pushState to virtual Home Assistant + // routes (e.g. /config/integrations/...), and a browser reload then + // requests that route from this server. Serve the harness page for any + // extensionless path — mirroring the real frontend's single-page-app + // routing — while keeping honest 404s for missing assets. + if (!extname(path)) { + path = '/pbt/harness/index.html'; + } + try { + const body = await readFile(join(ROOT, path)); + res.writeHead(200, { + 'Content-Type': MIME[extname(path)] ?? 'application/octet-stream' + }); + res.end(body); + } catch { + res.writeHead(404); + res.end('not found'); + } +}); + +server.listen(PORT, '127.0.0.1', () => { + console.log(`harness at http://127.0.0.1:${PORT}/pbt/harness/`); +}); diff --git a/pbt/spec.ts b/pbt/spec.ts new file mode 100644 index 00000000..58af9e94 --- /dev/null +++ b/pbt/spec.ts @@ -0,0 +1,140 @@ +/** + * Bombadil specification for the Lock Code Manager card harness. + * + * Run via `yarn test:pbt`. Extractors run inside the browser; they must + * walk shadow roots explicitly because Lit renders into (open) shadow DOM, + * which document-level queries and innerText do not pierce. + */ +/* eslint-disable no-underscore-dangle -- window.__lcmHarness is the harness's fixed extractor surface */ +import { always, eventually, now } from '@antithesishq/bombadil'; +import { actions, extract } from '@antithesishq/bombadil/browser'; + +export * from '@antithesishq/bombadil/browser/defaults'; + +/** Recursively collect visible text across light and shadow DOM. */ +function deepText(root: Element | ShadowRoot): string { + let text = ''; + for (const el of root.querySelectorAll('*')) { + if (el.shadowRoot) { + // Join with a separator so text nodes straddling a shadow + // boundary can't fuse into a digit run that isn't really there. + text += `${deepText(el.shadowRoot)}\n`; + } + } + text += root.textContent ?? ''; + return text; +} + +/** Recursively collect elements matching a selector across shadow roots. */ +function deepQueryAll(root: Element | Document | ShadowRoot, selector: string): Element[] { + const found = [...root.querySelectorAll(selector)]; + for (const el of root.querySelectorAll('*')) { + if (el.shadowRoot) { + found.push(...deepQueryAll(el.shadowRoot, selector)); + } + } + return found; +} + +declare global { + interface Window { + __lcmHarness?: { + model: { + revision: number; + slots: Record< + string, + { + active: boolean; + enabled: boolean; + inSync: boolean; + name: string; + pin: string; + } + >; + suspended: boolean; + }; + secretPins: () => string[]; + }; + } +} + +const maskedZoneText = extract((state) => { + const zone = state.document.querySelector('#masked-zone'); + return zone ? deepText(zone) : ''; +}); + +const lockCodesChipCount = extract((state) => { + const zone = state.document.querySelector('#lock-codes-zone'); + return zone ? deepQueryAll(zone, '.slot-chip').length : -1; +}); + +const secretPins = extract((state) => state.window.__lcmHarness?.secretPins() ?? []); + +const modelSlotCount = extract( + (state) => Object.keys(state.window.__lcmHarness?.model.slots ?? {}).length +); + +const modelNames = extract((state) => + Object.values(state.window.__lcmHarness?.model.slots ?? {}).map((slot) => slot.name) +); + +const cardText = extract((state) => deepText(state.document.body)); + +/** Masked cards must never render a secret PIN as cleartext. */ +export const maskedPinNeverLeaks = always(() => + secretPins.current.every( + (pin) => !new RegExp(`(? + lockCodesChipCount.current === -1 ? true : lockCodesChipCount.current === modelSlotCount.current +); + +/** Subscription liveness: pushed names eventually appear in the DOM. */ +export const namesEventuallyRendered = always(() => + eventually(() => modelNames.current.every((name) => cardText.current.includes(name))).within( + 10, + 'seconds' + ) +); + +const modelSuspended = extract((state) => state.window.__lcmHarness?.model.suspended ?? false); + +const suspendedBannerVisible = extract((state) => { + const zone = state.document.querySelector('#lock-codes-zone'); + return zone ? deepQueryAll(zone, '.suspended-banner').length > 0 : false; +}); + +/** Closest drivable "unavailable treatment": suspended state shows its banner. */ +export const suspendedStateEventuallyShowsBanner = always(() => + now(() => modelSuspended.current).implies( + eventually(() => suspendedBannerVisible.current || !modelSuspended.current).within( + 10, + 'seconds' + ) + ) +); + +/** Click chaos-panel buttons and card-internal (shadow DOM) buttons. */ +const clickablePoints = extract((state) => { + const points: { name: string; x: number; y: number }[] = []; + for (const el of deepQueryAll(state.document, 'button')) { + const rect = el.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) { + points.push({ + name: el.id || el.textContent?.trim().slice(0, 24) || 'button', + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2 + }); + } + } + return points; +}); + +export const clickButtons = actions(() => + clickablePoints.current.map(({ name, x, y }) => { + return { Click: { name, point: { x, y } } }; + }) +); diff --git a/pbt/tsconfig.json b/pbt/tsconfig.json new file mode 100644 index 00000000..62501829 --- /dev/null +++ b/pbt/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "bundler", + "noEmit": true, + "skipLibCheck": true + }, + "include": ["spec.ts"] +} diff --git a/pyproject.toml b/pyproject.toml index d2539dd5..b3c0c613 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -136,7 +136,7 @@ overgeneral-exceptions = ["BaseException", "Exception", "HomeAssistantError"] asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" testpaths = ["tests"] -norecursedirs = [".git", "testing_config"] +norecursedirs = [".git", "testing_config", ".hypothesis"] [tool.mypy] ignore_missing_imports = true diff --git a/requirements_test.txt b/requirements_test.txt index b295adad..33be3087 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,3 +1,4 @@ +hypothesis>=6.100 pylint-strict-informational>=0.1 pytest>=9.0.3 pytest-homeassistant-custom-component==0.13.348 diff --git a/tests/conftest.py b/tests/conftest.py index 64f4cb0c..91c1ed77 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,9 +4,11 @@ from collections.abc import Generator from datetime import timedelta +import os from typing import Any from unittest.mock import patch +from hypothesis import settings as hypothesis_settings import pytest from pytest_homeassistant_custom_component.common import ( MockConfigEntry, @@ -37,6 +39,16 @@ pytest_plugins = ["pytest_homeassistant_custom_component"] +# Hypothesis profiles: "dev" keeps the full-suite run within its +# seconds budget; CI opts into deeper exploration via HYPOTHESIS_PROFILE=ci. +# deadline=None in both: per-example deadlines flake under the HA test +# harness's timing noise and CI runner variance. +hypothesis_settings.register_profile("dev", max_examples=15, deadline=None) +hypothesis_settings.register_profile( + "ci", max_examples=200, deadline=None, print_blob=True +) +hypothesis_settings.load_profile(os.environ.get("HYPOTHESIS_PROFILE", "dev")) + TEST_DOMAIN = "test" diff --git a/tests/properties/__init__.py b/tests/properties/__init__.py new file mode 100644 index 00000000..facfdd38 --- /dev/null +++ b/tests/properties/__init__.py @@ -0,0 +1 @@ +"""Property-based tests (Hypothesis).""" diff --git a/tests/properties/test_credential_machine.py b/tests/properties/test_credential_machine.py new file mode 100644 index 00000000..8615a1bf --- /dev/null +++ b/tests/properties/test_credential_machine.py @@ -0,0 +1,176 @@ +"""Stateful property test for credential write orchestration. + +Drives the real BaseLock write path (rate limiting, duplicate detection, +connection checks, WriteResult handling) and LockUsercodeUpdateCoordinator +against MockLCMLock, comparing everything to a plain-dict oracle. +""" + +from __future__ import annotations + +import asyncio + +from hypothesis import strategies as st +from hypothesis.stateful import RuleBasedStateMachine, invariant, rule +import pytest +from pytest_homeassistant_custom_component.common import ( + MockConfigEntry, + async_test_home_assistant, +) + +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from custom_components.lock_code_manager.const import DOMAIN +from custom_components.lock_code_manager.domain.coordinator import ( + LockUsercodeUpdateCoordinator, +) +from custom_components.lock_code_manager.domain.exceptions import ( + DuplicateCodeError, + LockDisconnected, +) + +from ..common import MockLCMLock + +PINS = st.text(alphabet="0123456789", min_size=4, max_size=8) +SLOTS = st.integers(min_value=1, max_value=5) + + +class CredentialMachine(RuleBasedStateMachine): + """Random interleavings of writes, deletes, faults, and refreshes.""" + + def __init__(self) -> None: + super().__init__() + self.loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.loop) + self._hass_cm = async_test_home_assistant(self.loop) + self.hass = self.loop.run_until_complete(self._hass_cm.__aenter__()) + + self.config_entry = MockConfigEntry(domain=DOMAIN) + self.config_entry.add_to_hass(self.hass) + ent_reg = er.async_get(self.hass) + dev_reg = dr.async_get(self.hass) + lock_entity = ent_reg.async_get_or_create( + "lock", "test", "pbt_lock", config_entry=self.config_entry + ) + self.lock = MockLCMLock(self.hass, dev_reg, ent_reg, None, lock_entity) + self.lock.codes = {} + # Rate-limit delay between operations would dominate machine runtime. + self.lock._min_operation_delay = 0 + # Machine correctness also assumes an example never spans the + # coordinator's 10-second request-refresh cooldown: past it, the + # deferred debounced refresh could freshen coordinator.data between a + # rule's _would_duplicate read and its assertion. Examples run in + # milliseconds, leaving orders of magnitude of margin. + self.coordinator = LockUsercodeUpdateCoordinator( + self.hass, self.lock, self.config_entry + ) + self.lock.coordinator = self.coordinator + self._run(self.coordinator.async_refresh()) + + # Oracle: lock-truth we expect, slot -> pin. + self.expected: dict[int, str] = {} + + def _run(self, coro): + return self.loop.run_until_complete(coro) + + def _would_duplicate(self, slot: int, pin: str) -> bool: + # Mirror of the source _check_duplicate_code reads: coordinator.data, + # which may lag lock.codes until the next refresh. + return any( + other_slot != slot and credential.matches(pin) + for other_slot, credential in self.coordinator.data.items() + ) + + @rule(slot=SLOTS, pin=PINS) + def set_credential(self, slot: int, pin: str) -> None: + if self._would_duplicate(slot, pin): + before = dict(self.lock.codes) + with pytest.raises(DuplicateCodeError): + self._run( + self.lock.async_internal_set_usercode( + slot, pin, name=f"PBT user {slot}" + ) + ) + assert self.lock.codes == before + else: + already_set = self.lock.codes.get(slot) == pin + self._run( + self.lock.async_internal_set_usercode( + slot, pin, name=f"PBT user {slot}" + ) + ) + self.expected[slot] = pin + if not already_set: + # Names must reach the provider verbatim (tagging is a + # name-keyed-provider concern, not BaseLock's). + assert self.lock.service_calls["set_usercode"][-1] == ( + slot, + pin, + f"PBT user {slot}", + ) + + @rule(slot=SLOTS) + def delete_credential(self, slot: int) -> None: + self._run(self.lock.async_internal_clear_usercode(slot)) + self.expected.pop(slot, None) + + @rule(slot=SLOTS) + def set_same_pin_is_no_change(self, slot: int) -> None: + if slot not in self.expected: + return + pin = self.expected[slot] + if self._would_duplicate(slot, pin): + # An external change may have copied this PIN onto another slot; + # the duplicate guard fires before the no-change shortcut. + return + calls_before = len(self.lock.service_calls["set_usercode"]) + self._run( + self.lock.async_internal_set_usercode(slot, pin, name=f"PBT user {slot}") + ) + assert len(self.lock.service_calls["set_usercode"]) == calls_before + + @rule(slot=SLOTS, pin=PINS) + def external_change(self, slot: int, pin: str) -> None: + self.lock.codes[slot] = pin + self.expected[slot] = pin + + @rule() + def refresh_converges_coordinator(self) -> None: + self._run(self.coordinator.async_refresh()) + observed = { + slot: credential.readable_pin + for slot, credential in self.coordinator.data.items() + if credential.is_present + } + assert observed == self.expected + + @rule(slot=SLOTS, pin=PINS) + def write_while_disconnected_fails_loud(self, slot: int, pin: str) -> None: + self.lock.set_connected(False) + before = dict(self.lock.codes) + try: + with pytest.raises(LockDisconnected): + self._run( + self.lock.async_internal_set_usercode( + slot, pin, name=f"PBT user {slot}" + ) + ) + assert self.lock.codes == before + finally: + self.lock.set_connected(True) + + @invariant() + def lock_state_matches_oracle(self) -> None: + assert self.lock.codes == self.expected + + def teardown(self) -> None: + async def _shutdown() -> None: + await self.coordinator.async_shutdown() + await self.hass.async_stop(force=True) + await self._hass_cm.__aexit__(None, None, None) + + self.loop.run_until_complete(_shutdown()) + self.loop.close() + asyncio.set_event_loop(None) + + +TestCredentialMachine = CredentialMachine.TestCase diff --git a/tests/properties/test_in_sync.py b/tests/properties/test_in_sync.py new file mode 100644 index 00000000..71bf1230 --- /dev/null +++ b/tests/properties/test_in_sync.py @@ -0,0 +1,108 @@ +"""Property-based tests for SlotSyncManager.calculate_in_sync.""" + +from __future__ import annotations + +from types import SimpleNamespace + +from hypothesis import given, strategies as st + +from homeassistant.const import STATE_OFF, STATE_ON + +from custom_components.lock_code_manager.domain.models import SlotCredential +from custom_components.lock_code_manager.domain.sync import SlotState, SlotSyncManager + +PINS = st.text(alphabet="0123456789", min_size=4, max_size=8) +LAST_SET = st.one_of(st.none(), PINS) +CREDENTIALS = st.one_of( + st.none(), + st.just(SlotCredential.empty()), + st.just(SlotCredential.unreadable()), + PINS.map(SlotCredential.known), +) +SLOT_STATES = st.builds( + SlotState, + active_state=st.sampled_from([STATE_ON, STATE_OFF]), + pin_state=PINS, + name_state=st.one_of(st.none(), st.text(max_size=20)), + code_state=st.one_of(st.just(""), PINS), + coordinator_code=CREDENTIALS, +) + + +def _manager(*, verified: bool, last_set_pin: str | None) -> SlotSyncManager: + # __new__ skips the heavyweight __init__ (hass, registries, entities); + # calculate_in_sync only touches these three attributes. + manager = SlotSyncManager.__new__(SlotSyncManager) + manager._slot_num = 1 + manager._coordinator = SimpleNamespace(is_verified=lambda slot_num: verified) + manager._last_set_pin = last_set_pin + return manager + + +@given(slot_state=SLOT_STATES, last_set_pin=LAST_SET) +def test_unverified_slot_is_never_in_sync( + slot_state: SlotState, last_set_pin: str | None +) -> None: + """An optimistic write awaiting confirmation can never read as in sync.""" + manager = _manager(verified=False, last_set_pin=last_set_pin) + assert manager.calculate_in_sync(slot_state) is False + + +@given(pin=PINS, credential_pin=PINS, last_set_pin=LAST_SET) +def test_active_readable_credential_syncs_iff_pin_matches( + pin: str, credential_pin: str, last_set_pin: str | None +) -> None: + """Active slot with a readable code: in sync exactly when PINs match.""" + manager = _manager(verified=True, last_set_pin=last_set_pin) + state = SlotState(STATE_ON, pin, None, "", SlotCredential.known(credential_pin)) + assert manager.calculate_in_sync(state) is (pin == credential_pin) + + +@given(pin=PINS, last_set_pin=LAST_SET) +def test_active_empty_credential_trusts_recent_set_only( + pin: str, last_set_pin: str | None +) -> None: + """Active + lock reports empty: in sync only if we just set this exact PIN.""" + manager = _manager(verified=True, last_set_pin=last_set_pin) + state = SlotState(STATE_ON, pin, None, "", SlotCredential.empty()) + assert manager.calculate_in_sync(state) is ( + last_set_pin is not None and pin == last_set_pin + ) + + +@given(pin=PINS, last_set_pin=LAST_SET) +def test_active_unreadable_credential_compares_last_set( + pin: str, last_set_pin: str | None +) -> None: + """Active + write-only code: in sync iff configured PIN equals last set.""" + manager = _manager(verified=True, last_set_pin=last_set_pin) + state = SlotState(STATE_ON, pin, None, "", SlotCredential.unreadable()) + assert manager.calculate_in_sync(state) is (pin == last_set_pin) + + +@given(pin=PINS, code=st.one_of(st.just(""), PINS), last_set_pin=LAST_SET) +def test_active_without_coordinator_data_falls_back_to_code_sensor( + pin: str, code: str, last_set_pin: str | None +) -> None: + """No coordinator data: the code sensor entity is the comparison source.""" + manager = _manager(verified=True, last_set_pin=last_set_pin) + state = SlotState(STATE_ON, pin, None, code, None) + assert manager.calculate_in_sync(state) is (pin == code) + + +@given(slot_state=SLOT_STATES, last_set_pin=LAST_SET) +def test_inactive_slot_syncs_iff_lock_side_empty( + slot_state: SlotState, last_set_pin: str | None +) -> None: + """Inactive slot: in sync exactly when the lock side shows no code.""" + manager = _manager(verified=True, last_set_pin=last_set_pin) + state = SlotState( + STATE_OFF, + slot_state.pin_state, + slot_state.name_state, + slot_state.code_state, + slot_state.coordinator_code, + ) + credential = state.coordinator_code + expected = credential.is_empty if credential is not None else state.code_state == "" + assert manager.calculate_in_sync(state) is expected diff --git a/tests/properties/test_pin_generator.py b/tests/properties/test_pin_generator.py new file mode 100644 index 00000000..d2bd2c3e --- /dev/null +++ b/tests/properties/test_pin_generator.py @@ -0,0 +1,62 @@ +"""Property-based tests for random PIN generation.""" + +from __future__ import annotations + +from hypothesis import given, strategies as st +import pytest + +from custom_components.lock_code_manager.domain.pin_generator import ( + MAX_PIN_LENGTH, + MIN_PIN_LENGTH, + generate_pin, + is_unsafe_pin, +) + +LENGTHS = st.integers(min_value=MIN_PIN_LENGTH, max_value=MAX_PIN_LENGTH) +DIGITS = "0123456789" + + +@given(length=LENGTHS) +def test_generate_pin_returns_safe_digits_of_requested_length(length: int) -> None: + """Generated PINs are always the right length, numeric, and never unsafe.""" + pin = generate_pin(length) + assert len(pin) == length + assert pin.isdigit() + assert not is_unsafe_pin(pin) + + +@given( + length=st.integers(min_value=-100, max_value=100).filter( + lambda n: not MIN_PIN_LENGTH <= n <= MAX_PIN_LENGTH + ) +) +def test_generate_pin_rejects_out_of_range_lengths(length: int) -> None: + """Lengths outside [MIN_PIN_LENGTH, MAX_PIN_LENGTH] raise ValueError.""" + with pytest.raises(ValueError): + generate_pin(length) + + +@given(digit=st.sampled_from(DIGITS), length=LENGTHS) +def test_all_same_digits_is_unsafe(digit: str, length: int) -> None: + """Any repdigit PIN is rejected as unsafe.""" + assert is_unsafe_pin(digit * length) + + +@given( + base=st.text(alphabet=DIGITS, min_size=1, max_size=4), + repeats=st.integers(min_value=2, max_value=4), +) +def test_repeating_subsequence_is_unsafe(base: str, repeats: int) -> None: + """Any PIN that is a shorter block repeated (1212, 123123, ...) is unsafe.""" + assert is_unsafe_pin(base * repeats) + + +@given( + start=st.integers(min_value=0, max_value=9), + step=st.sampled_from([1, -1]), + length=LENGTHS, +) +def test_sequential_with_wrap_is_unsafe(start: int, step: int, length: int) -> None: + """Fully ascending/descending runs, including 9->0 / 0->9 wrap, are unsafe.""" + pin = "".join(str((start + i * step) % 10) for i in range(length)) + assert is_unsafe_pin(pin) diff --git a/tests/properties/test_tag_codec.py b/tests/properties/test_tag_codec.py new file mode 100644 index 00000000..e36cecbf --- /dev/null +++ b/tests/properties/test_tag_codec.py @@ -0,0 +1,82 @@ +"""Property-based tests for the provider slot-tag codec.""" + +from __future__ import annotations + +from hypothesis import given, strategies as st + +from custom_components.lock_code_manager.providers._util import ( + make_compact_tagged_name, + make_tagged_name, + parse_slot_num, + parse_tag, +) + +SLOT_NUMS = st.integers(min_value=1, max_value=9999) + +# Documented contract edges (see _util.py regexes): the canonical pattern +# strips leading whitespace after "lcm::" via \s*, and "." never +# crosses a newline — so names starting with whitespace or containing +# newlines cannot round-trip by design. The strategy encodes that contract. +NAMES = st.text(min_size=1, max_size=40).filter( + lambda s: "\n" not in s and not s[0].isspace() +) + + +@given(slot=SLOT_NUMS, name=NAMES) +def test_canonical_round_trip(slot: int, name: str) -> None: + """Canonical lcm:: encodes and decodes losslessly.""" + assert parse_tag(make_tagged_name(slot, name)) == (slot, name) + + +@given(slot=SLOT_NUMS) +def test_default_name_round_trip(slot: int) -> None: + """Omitted name falls back to the documented 'Code Slot N' display.""" + assert parse_tag(make_tagged_name(slot)) == (slot, f"Code Slot {slot}") + + +@given(slot=SLOT_NUMS) +def test_compact_round_trip(slot: int) -> None: + """Compact lcm preserves the slot binding with empty display.""" + assert parse_tag(make_compact_tagged_name(slot)) == (slot, "") + + +@given(slot=SLOT_NUMS) +def test_slot_only_round_trip(slot: int) -> None: + """Bare digits parse as a slot tag (documented, intentionally ambiguous).""" + assert parse_tag(str(slot)) == (slot, "") + + +@given(slot=SLOT_NUMS, name=NAMES) +def test_legacy_format_still_parses(slot: int, name: str) -> None: + """Read-only legacy [LCM:] is still recognized.""" + assert parse_tag(f"[LCM:{slot}] {name}") == (slot, name) + + +@given(name=st.text(max_size=60)) +def test_parse_tag_is_total(name: str) -> None: + """parse_tag never raises; non-tags come back unchanged.""" + slot, friendly = parse_tag(name) + if slot is None: + assert friendly == name + else: + assert isinstance(slot, int) + + +@given( + value=st.one_of( + st.none(), + st.booleans(), + st.integers(), + st.floats(allow_nan=True, allow_infinity=True), + st.text(), + st.lists(st.integers(), max_size=3), + ) +) +def test_parse_slot_num_is_total(value: object) -> None: + """parse_slot_num never raises; bools are rejected, ints pass through.""" + result = parse_slot_num(value) + assert result is None or isinstance(result, int) + if isinstance(value, bool): + assert result is None + elif isinstance(value, int): + assert result == value diff --git a/yarn.lock b/yarn.lock index f700229b..4a39ac6c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7,6 +7,11 @@ resolved "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz" integrity sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA== +"@antithesishq/bombadil@0.6.1": + version "0.6.1" + resolved "https://registry.yarnpkg.com/@antithesishq/bombadil/-/bombadil-0.6.1.tgz#89c442de80879940497e31e53ae98c57f019b9bd" + integrity sha512-d1iufG3MI7gSMSiSmMeNdcMW+qR0yQXL2zdkVynC3n3DYgFJYlYXKUQzygmqU12m4RWlR5iOdQU1hsx5UT6+IA== + "@asamuzakjp/css-color@^6.0.5": version "6.0.5" resolved "https://registry.yarnpkg.com/@asamuzakjp/css-color/-/css-color-6.0.5.tgz#f4edcf3295e5fdee655fce3bcd11ce09411089d8"