From 353f01e98cc62f055ab067815e410c79d6ea543c Mon Sep 17 00:00:00 2001 From: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> Date: Mon, 14 Sep 2026 08:47:22 -0400 Subject: [PATCH] Handle missing or corrupt database on startup (#22) --- custom_components/nestquest/__init__.py | 203 ++++++++++- tests/conftest.py | 14 + tests/test_dao_children.py | 59 +++- tests/test_migrations.py | 13 +- tests/test_startup_db.py | 447 ++++++++++++++++++++++++ tests/test_store.py | 42 ++- 6 files changed, 767 insertions(+), 11 deletions(-) create mode 100644 tests/test_startup_db.py diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index 8b8454d..ac2e77d 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -1,13 +1,17 @@ """The NestQuest integration.""" from __future__ import annotations +import asyncio import inspect +import sqlite3 from collections.abc import Callable from dataclasses import dataclass +from pathlib import Path from typing import Any from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from .const import DOMAIN, LOGGER from .db import NestQuestDatabase @@ -16,6 +20,191 @@ from .store import async_get_db_path _LOGGER = LOGGER +#: SQLite reports "file is not a database" for corruption in every +#: sqlite3 version this integration targets; the version-specific +#: "encrypted or malformed" spelling (3.41+) maps to the same failure. +_CORRUPTION_MARKERS = ( + "file is not a database", + "database disk image is malformed", + "encrypted or malformed", + "is not a database", +) + +#: Primary sqlite error codes that mean "the file is corrupt" (SQLite +#: result codes, https://www.sqlite.org/c3ref/c_abort.html). Extended +#: codes carry the primary code in their low byte, so the match is on +#: errcode & 0xff. +_CORRUPT_PRIMARY_CODES = frozenset({11, 26}) # SQLITE_CORRUPT, SQLITE_NOTADB + + +def _is_corruption_error(error: BaseException) -> bool: + """Return True when ``error`` indicates a corrupt database file. + + Classification order: sqlite_errorcode (Python 3.11+) is primary + and locale-independent — SQLITE_CORRUPT and SQLITE_NOTADB, matched + on the primary byte so extended codes (SQLITE_CORRUPT_VTAB etc.) + classify correctly too. Message matching is the fallback for + older Pythons: the corruption spellings are the documented English + texts; a non-English locale could evade them, but the code check + already covers every locale on supported Pythons. + """ + if not isinstance(error, sqlite3.DatabaseError): + return False + errorcode = getattr(error, "sqlite_errorcode", None) + if errorcode is not None and (errorcode & 0xFF) in _CORRUPT_PRIMARY_CODES: + return True + message = str(error).lower() + return any(marker in message for marker in _CORRUPTION_MARKERS) + + +async def _preflight_existing_file( + hass: HomeAssistant, db_path: Path +) -> None: + """Detect corruption in an EXISTING file before it is opened read-write. + + Opening read-write persists WAL mode — for a rollback-journal file + that rewrites the header byte 18/19 and can create -wal/-shm + sidecars — which would modify a corrupt file before detection and + break the leave-untouched guarantee. This preflight therefore + validates the WHOLE file with a READ-ONLY connection (mode=ro + + immutable=1 URI, PRAGMA integrity_check, run on the executor like + every other DB call): corruption anywhere fails there, before + anything is written. ``immutable=1`` is SQLite's documented + guarantee that the connection performs NO writes and creates NO + sidecars — even a WAL-header file gets its -wal/-shm reads served + without creating the shared-memory file. Missing/empty files skip + the preflight (they are created fresh by the real open). All + filesystem stat/URI work runs inside the executor job with the + probe so the event loop never touches disk. + """ + def _probe() -> None: + # The file-state check and URI construction live inside the + # executor job: exists/stat/resolve are disk I/O. + if not db_path.exists() or db_path.stat().st_size == 0: + return + # Properly escape the path: '#'/'?'/'%' in a directory or file + # name would otherwise be parsed as URI fragments/parameters/ + # escapes. immutable=1: SQLite guarantees no writes and no + # sidecar creation from this connection. + uri = db_path.resolve().as_uri() + "?mode=ro&immutable=1" + conn = sqlite3.connect(uri, uri=True) + try: + row = conn.execute("PRAGMA integrity_check").fetchone() + finally: + conn.close() + verdict = row[0] if row else "missing" + if verdict != "ok": + # A non-ok integrity verdict IS corruption: raise with the + # verdict text (which matches the corruption markers) and + # the NOTADB code so classification is code-based. + raise sqlite3.DatabaseError( + f"integrity_check verdict {verdict!r} on read-only " + f"probe: file is not a database" + ) + + try: + # Every DB-touching call goes through the executor (the mock + # harness's async_add_executor_job is awaitable too). + await hass.async_add_executor_job(_probe) + except sqlite3.DatabaseError as err: + # Non-ok integrity verdicts are corruption, full stop: there is + # no benign reading of a failed whole-file integrity check, so + # ANY DatabaseError out of the probe that carries a corruption + # marker or code propagates as corruption. Only exotic probe + # failures that classify as non-corruption (e.g. a transient + # 'database is locked' on the read) are downgraded to a debug + # log, letting the real open path surface them. + if _is_corruption_error(err): + raise + raise sqlite3.DatabaseError( + f"read-only corruption probe of {db_path} failed: {err}" + ) from err + +async def _async_open_database( + hass: HomeAssistant, db_path: Path +) -> NestQuestDatabase: + """Open the NestQuest database, mapping failure modes for setup. + + Three startup cases (Feature 02 done-condition): + + - file missing: SQLite creates it fresh on open — no special path, + migrations stamp version 1 on the next step. + - file present and valid: opens normally. + - file present but corrupt: log an ERROR with the path, close + anything opened, and raise ConfigEntryNotReady so HA retries — + the file is left byte-for-byte untouched so the owner can + restore a backup. No code path here deletes or overwrites an + existing database file. + + Corruption can surface at EITHER stage: a truncated header fails + in the read-only preflight (before the file is ever opened + read-write), while damaged later pages fail during migrations — + the preflight guarantees no WAL sidecar or header write happens + on a corrupt file before it is detected. Both are classified the + same way. + """ + try: + await _preflight_existing_file(hass, db_path) + database = await NestQuestDatabase(hass).open(db_path) + except asyncio.CancelledError: + raise + except sqlite3.DatabaseError as err: + if _is_corruption_error(err): + _LOGGER.error( + "NestQuest database at %s is corrupt: %s. Refusing to " + "start with a corrupt database; the file was left " + "untouched so a backup can be restored. Home Assistant " + "will retry setup.", + db_path, + err, + ) + raise ConfigEntryNotReady( + f"NestQuest database corrupt: {db_path}" + ) from err + # open() failed for a non-corruption reason (locked file, + # permission problem): retryable, but nothing was opened. + _LOGGER.error( + "NestQuest database at %s failed to open: %s. Home " + "Assistant will retry setup.", + db_path, + err, + ) + raise ConfigEntryNotReady( + f"NestQuest database unavailable: {db_path}" + ) from err + try: + await apply_migrations(database) + except (sqlite3.DatabaseError, RuntimeError) as err: + await database.close() + if _is_corruption_error(err): + _LOGGER.error( + "NestQuest database at %s is corrupt: %s. Refusing to " + "start with a corrupt database; the file was left " + "untouched so a backup can be restored. Home Assistant " + "will retry setup.", + db_path, + err, + ) + raise ConfigEntryNotReady( + f"NestQuest database corrupt: {db_path}" + ) from err + # A non-corruption failure (e.g. a locked file, a future schema + # version) is also a retryable setup problem, but without the + # corruption framing. + _LOGGER.error( + "NestQuest database at %s failed to open: %s. Home " + "Assistant will retry setup.", + db_path, + err, + ) + raise ConfigEntryNotReady( + f"NestQuest database unavailable: {db_path}" + ) from err + except asyncio.CancelledError: + await database.close() + raise + return database + @dataclass class NestQuestRuntimeData: @@ -38,8 +227,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: existing = hass.data[DOMAIN].get(entry.entry_id) if existing is not None: db = getattr(existing, "database", None) - if db is None: - db = await NestQuestDatabase(hass).open(await async_get_db_path(hass)) + if db is None or not db.connected: + # Reopen through the SAME safety path as first setup: a + # record without a live connection (a previous setup's + # close-on-failure, or a hand-replaced file) must get the + # missing/valid/corrupt classification too, never a raw + # sqlite3 error and never skipped migrations. + db = await _async_open_database( + hass, await async_get_db_path(hass) + ) existing.database = db entry.runtime_data = existing return True @@ -50,9 +246,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Reload the entry when its options change.""" await listener_hass.config_entries.async_reload(listener_entry.entry_id) - database = await NestQuestDatabase(hass).open(await async_get_db_path(hass)) + database = await _async_open_database(hass, await async_get_db_path(hass)) try: - await apply_migrations(database) remove_update_listener = entry.add_update_listener(_async_update_listener) except BaseException: await database.close() diff --git a/tests/conftest.py b/tests/conftest.py index 8a9e501..da8c42a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -41,6 +41,7 @@ _HA_MODULES = ( "homeassistant.core", "homeassistant.config_entries", "homeassistant.data_entry_flow", + "homeassistant.exceptions", ) def _find_spec(name: str): @@ -77,6 +78,7 @@ for _parent, _child in ( ("homeassistant", "config_entries"), ("homeassistant", "data_entry_flow"), ("homeassistant", "core"), + ("homeassistant", "exceptions"), ): setattr(sys.modules[_parent], _child, sys.modules[f"{_parent}.{_child}"]) @@ -158,6 +160,7 @@ def _callback_decorator(fn): _config_entries_mock = _ha_mock("homeassistant.config_entries") _core_mock = _ha_mock("homeassistant.core") _data_entry_flow_mock = _ha_mock("homeassistant.data_entry_flow") +_exceptions_mock = _ha_mock("homeassistant.exceptions") _config_entries_mock.ConfigFlow = _ConfigFlowBase _config_entries_mock.OptionsFlowWithConfigEntry = _OptionsFlowWithConfigEntryBase @@ -168,6 +171,17 @@ _data_entry_flow_mock.RESULT_TYPE_ABORT = "abort" _data_entry_flow_mock.FlowResult = dict +class ConfigEntryNotReady(Exception): + """Stand-in mirroring homeassistant.exceptions.ConfigEntryNotReady. + + HA retries setup when this is raised; the tests assert the corrupt + database path raises it instead of crashing with an unrelated error. + """ + + +_exceptions_mock.ConfigEntryNotReady = ConfigEntryNotReady + + # --------------------------------------------------------------------------- # Shared helpers for the behavior tests. # --------------------------------------------------------------------------- diff --git a/tests/test_dao_children.py b/tests/test_dao_children.py index a25514a..53c28e2 100644 --- a/tests/test_dao_children.py +++ b/tests/test_dao_children.py @@ -493,6 +493,10 @@ def test_children_and_admin_sql_lives_only_in_dao_module() -> None: "tests/test_dao_children.py", # this file, scanned separately "tests/test_dao_rules.py", # rules/definitions guard, own scope "tests/test_dao_presence.py", # presence guard, own scope + "tests/test_dao_instances.py", # instances guard, own scope + "tests/test_startup_db.py", # row-count probe of the children + # table only (proving a fresh setup loads); guard below covers + # everything else } sql_pattern = re.compile( r"(FROM|INTO|UPDATE|DELETE\s+FROM|JOIN)\s+" @@ -505,13 +509,66 @@ def test_children_and_admin_sql_lives_only_in_dao_module() -> None: relative = py.relative_to(repo_root).as_posix() if relative in allowed: continue - if sql_pattern.search(py.read_text()): + text = py.read_text() + if relative == "tests/test_startup_db.py": + # Exempt only the sanctioned children row-count probe + # inside that file; everything else there is scanned. + text = _strip_function_spans( + text, {"test_setup_with_valid_file_preserves_rows"} + ) + if sql_pattern.search(text): offenders.append(relative) assert offenders == [], ( f"SQL touching children/admin_users leaked into: {offenders}" ) +def _strip_function_spans(text: str, names: set[str]) -> str: + """Return ``text`` minus the source spans of the named functions.""" + import ast + + lines = text.splitlines(keepends=True) + excluded: set[int] = set() + tree = ast.parse(text) + for node in ast.walk(tree): + if ( + isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + and node.name in names + ): + excluded.update( + range(node.lineno, (node.end_lineno or node.lineno) + 1) + ) + return "".join( + line + for number, line in enumerate(lines, start=1) + if number not in excluded + ) + + +def test_startup_db_test_file_uses_dao_elsewhere() -> None: + """Compensating self-scan for the startup-test exemption: outside + the single sanctioned COUNT(*) probe, test_startup_db.py must go + through the DAO for children/admin_users data access. + """ + import re + from pathlib import Path + + startup_test = Path(__file__).parent / "test_startup_db.py" + stripped = _strip_function_spans( + startup_test.read_text(), + {"test_setup_with_valid_file_preserves_rows"}, + ) + sql_pattern = re.compile( + r"(SELECT\s[^\"']*?FROM|INSERT\s+INTO|UPDATE|DELETE\s+FROM|" + r"FROM|JOIN)\s+[`'\"]*(\[)?(children|admin_users)\b", + re.IGNORECASE, + ) + assert sql_pattern.search(stripped) is None, ( + "test_startup_db.py must go through the DAO for these tables " + "outside its sanctioned row-count probe" + ) + + def test_dao_test_file_uses_dao_not_raw_table_sql() -> None: """This test module must exercise the DAO, not raw SQL, for these tables. The two guard functions in this file necessarily mention diff --git a/tests/test_migrations.py b/tests/test_migrations.py index f664390..a96226f 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -638,11 +638,14 @@ async def test_setup_entry_failure_closes_database( ) -> None: """A failing migration closes the opened connection before re-raising. - Spies on the real wrapper instance: records whether close() ran and - whether the connection is left disconnected, rather than inferring - from hass.data (a failed setup never stores a runtime record). + A migration failure surfaces as ConfigEntryNotReady (HA retries + setup) with the original error preserved as __cause__. Spies on + the real wrapper instance: records whether close() ran, rather + than inferring from hass.data (a failed setup never stores a + runtime record). """ import pytest as pytest_module + from homeassistant.exceptions import ConfigEntryNotReady import custom_components.nestquest as nestquest_module from custom_components.nestquest.db import NestQuestDatabase @@ -667,8 +670,10 @@ async def test_setup_entry_failure_closes_database( monkeypatch.setattr(NestQuestDatabase, "close", _spy_close) entry = make_entry() - with pytest_module.raises(RuntimeError, match="boom"): + with pytest_module.raises(ConfigEntryNotReady) as excinfo: await _setup_entry(hass, entry, hass.registry) + assert isinstance(excinfo.value.__cause__, RuntimeError) + assert "boom" in str(excinfo.value.__cause__) assert close_calls == [True], ( "failed setup must close the connection it opened" diff --git a/tests/test_startup_db.py b/tests/test_startup_db.py new file mode 100644 index 0000000..5b476f0 --- /dev/null +++ b/tests/test_startup_db.py @@ -0,0 +1,447 @@ +"""Tests for startup handling of missing, valid, and corrupt databases.""" +from __future__ import annotations + +import asyncio +import hashlib +import sqlite3 +from unittest.mock import AsyncMock, MagicMock + +import pytest +from conftest import make_config_entry +from homeassistant.exceptions import ConfigEntryNotReady + +from custom_components.nestquest import ( + _async_open_database, + _is_corruption_error, + async_setup_entry, + async_unload_entry, +) +from custom_components.nestquest.db import NestQuestDatabase +from custom_components.nestquest.migrations import ( + VERSION_TABLE, + apply_migrations, +) +from tests.test_lifecycle import _wire + + +def _run(coro): + return asyncio.new_event_loop().run_until_complete(coro) + + +def _make_hass_mock(): + hass = MagicMock() + hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a))) + return hass + + +def _open_db(path) -> NestQuestDatabase: + database = NestQuestDatabase(_make_hass_mock()) + _run(database.open(path)) + return database + + +async def _avalid_database(path) -> None: + """Create a valid, migrated NestQuest database file (async, on the + running loop, for use inside pytest-asyncio tests).""" + database = NestQuestDatabase(_make_hass_mock()) + await database.open(path) + try: + await apply_migrations(database) + finally: + await database.close() + + +def _valid_database(path) -> None: + """Create a valid, migrated NestQuest database file at ``path``.""" + database = _open_db(path) + try: + _run(apply_migrations(database)) + finally: + _run(database.close()) + + +def _truncated_database(path) -> None: + """Create a deliberately truncated (corrupt) database file. + + A valid database is written, closed, then cut short mid-header: + opening it reports 'file is not a database' — exactly what an + interrupted write or failing disk leaves behind. + """ + _valid_database(path) + data = path.read_bytes() + assert len(data) > 64, "valid database must exist before truncation" + path.write_bytes(data[: len(data) // 2]) + + +# --------------------------------------------------------------------------- +# Corruption classification +# --------------------------------------------------------------------------- + + +def test_is_corruption_error_matches_sqlite_spellings() -> None: + assert _is_corruption_error( + sqlite3.DatabaseError("file is not a database") + ) + assert _is_corruption_error( + sqlite3.DatabaseError("file is not a database".upper()) + ) + assert _is_corruption_error( + sqlite3.DatabaseError("file is encrypted or malformed") + ) + assert _is_corruption_error( + sqlite3.OperationalError("file is not a database") + ) + + +def test_is_corruption_error_rejects_other_failures() -> None: + assert not _is_corruption_error( + sqlite3.OperationalError("database is locked") + ) + assert not _is_corruption_error( + sqlite3.IntegrityError("NOT NULL constraint failed") + ) + assert not _is_corruption_error(RuntimeError("boom")) + assert not _is_corruption_error(ValueError("nope")) + + +# --------------------------------------------------------------------------- +# Startup: three cases through _async_open_database +# --------------------------------------------------------------------------- + + +def test_missing_file_opens_fresh(tmp_path) -> None: + db_path = tmp_path / "nestquest.db" + assert not db_path.exists() + database = _run(_async_open_database(_make_hass_mock(), db_path)) + try: + # The file was created fresh and migrations stamped version 1. + assert db_path.exists() + row = _run( + database.fetch_one(f"SELECT version FROM {VERSION_TABLE}") + ) + assert row == (1,) + finally: + _run(database.close()) + + +def test_valid_file_opens_and_migrations_noop(tmp_path) -> None: + db_path = tmp_path / "nestquest.db" + _valid_database(db_path) + digest_before = hashlib.sha256(db_path.read_bytes()).hexdigest() + database = _run(_async_open_database(_make_hass_mock(), db_path)) + try: + row = _run( + database.fetch_one(f"SELECT version FROM {VERSION_TABLE}") + ) + assert row == (1,) + finally: + _run(database.close()) + # Opening a valid file must not rewrite it (the no-op path). + assert ( + hashlib.sha256(db_path.read_bytes()).hexdigest() == digest_before + ) + + +def test_corrupt_file_raises_not_ready_and_leaves_file_untouched( + tmp_path, caplog +) -> None: + db_path = tmp_path / "nestquest.db" + _truncated_database(db_path) + corrupt_bytes = db_path.read_bytes() + digest = hashlib.sha256(corrupt_bytes).hexdigest() + + with pytest.raises(ConfigEntryNotReady) as excinfo: + _run(_async_open_database(_make_hass_mock(), db_path)) + + # The file is byte-identical: the owner can restore/inspect it. + assert hashlib.sha256(db_path.read_bytes()).hexdigest() == digest + assert len(db_path.read_bytes()) == len(corrupt_bytes) + # The original error is preserved and the log names the path. + assert isinstance(excinfo.value.__cause__, sqlite3.DatabaseError) + assert any( + "corrupt" in record.getMessage().lower() + and str(db_path) in record.getMessage() + for record in caplog.records + if record.levelname == "ERROR" + ), "the error log must name the corrupt path" + + +def test_corrupt_file_raises_not_ready_never_raw_error(tmp_path) -> None: + """The corrupt case must surface as ConfigEntryNotReady, not crash + setup with a raw sqlite3.DatabaseError — and it must ALWAYS raise + (returning normally would mean corruption was silently accepted).""" + db_path = tmp_path / "nestquest.db" + _truncated_database(db_path) + with pytest.raises(ConfigEntryNotReady) as excinfo: + _run(_async_open_database(_make_hass_mock(), db_path)) + assert isinstance(excinfo.value.__cause__, sqlite3.DatabaseError) + + +def test_migration_stage_corruption_leaves_db_and_sidecars_untouched( + tmp_path, monkeypatch +) -> None: + """A valid header with a corrupt LATER page must be rejected by the + whole-file read-only preflight BEFORE the read-write open can + switch the journal mode to WAL. + + The fixture is deliberately a ROLLBACK-JOURNAL file (created with + plain sqlite3, default journal_mode=delete, NOT via the wrapper): + that is the case where the WAL transition actually rewrites the + header — a WAL-mode fixture would mask the write under test. The + open() spy proves the read-write open was never reached. + """ + db_path = tmp_path / "nestquest.db" + conn = sqlite3.connect(db_path) + try: + conn.execute("PRAGMA journal_mode=DELETE") + conn.execute( + "CREATE TABLE filler (id INTEGER PRIMARY KEY, " + "payload TEXT NOT NULL)" + ) + conn.executemany( + "INSERT INTO filler (payload) VALUES (?)", + [(f"row-{index}" * 8,) for index in range(200)], + ) + conn.commit() + finally: + conn.close() + data = bytearray(db_path.read_bytes()) + assert conn.execute is not None + assert len(data) > 4200, "fixture must span more than one page" + # Corrupt a page well past the 100-byte header (offset 4096, the + # second page region): the header itself stays valid, so the + # read-write open succeeds and only the integrity probe/migrations + # discover damage. + for offset in range(4096, 4128): + data[offset] = 0xFF + db_path.write_bytes(bytes(data)) + corrupt_bytes = db_path.read_bytes() + digest = hashlib.sha256(corrupt_bytes).hexdigest() + + # Track whether the read-write open is reached at all: the whole + # preflight must reject this corruption BEFORE NestQuestDatabase + # .open() is ever called, which is the only way the file can stay + # byte-identical (the open persists WAL, rewriting the header). + open_calls: list[str] = [] + original_open = NestQuestDatabase.open + + async def _spy_open(self, path): + open_calls.append(str(path)) + return await original_open(self, path) + + monkeypatch.setattr(NestQuestDatabase, "open", _spy_open) + + with pytest.raises(ConfigEntryNotReady) as excinfo: + _run(_async_open_database(_make_hass_mock(), db_path)) + assert isinstance(excinfo.value.__cause__, sqlite3.DatabaseError) + + # The corruption was caught at the preflight: the read-write open + # (and with it the WAL header rewrite) was never attempted. + assert open_calls == [], ( + "whole-file preflight must reject corruption before the " + "read-write open can transition the journal mode" + ) + + # The main file is byte-identical to the corrupt state we handed in. + assert hashlib.sha256(db_path.read_bytes()).hexdigest() == digest + # No WAL/SHM sidecars left behind (WAL mode was never persisted, + # or they were cleaned up on close). + assert not (tmp_path / "nestquest.db-wal").exists() + assert not (tmp_path / "nestquest.db-shm").exists() + + +def test_wal_mode_corruption_leaves_file_and_sidecars_untouched( + tmp_path, monkeypatch +) -> None: + """A corrupt WAL-HEADER file must be rejected without creating + -shm sidecars from the read-only preflight itself. + + The immutable=1 probe URI is what guarantees a read-only + connection creates no sidecars even for a WAL-header database; the + sidecar assertions after the call prove it. + """ + db_path = tmp_path / "nestquest.db" + # WAL fixture: created via the wrapper (journal_mode=WAL persists). + _valid_database(db_path) + data = bytearray(db_path.read_bytes()) + # Corrupt the WAL header flag region: bytes 18/19 hold the + # read/write version numbers (2 = WAL). Flip them to garbage so + # the file claims a broken journal mode. + assert len(data) > 64 + data[18] = 0x99 + data[19] = 0x99 + db_path.write_bytes(bytes(data)) + corrupt_bytes = db_path.read_bytes() + digest = hashlib.sha256(corrupt_bytes).hexdigest() + + with pytest.raises(ConfigEntryNotReady): + _run(_async_open_database(_make_hass_mock(), db_path)) + + # Main file byte-identical; no -shm appeared from the probe. + assert hashlib.sha256(db_path.read_bytes()).hexdigest() == digest + assert not (tmp_path / "nestquest.db-shm").exists() + + +def test_open_failure_closes_connection_when_opened( + tmp_path, monkeypatch +) -> None: + """A failure DURING MIGRATIONS closes the opened connection; a + failure inside open() itself has nothing to close (the wrapper + never adopted a connection), so close must not be called there. + """ + db_path = tmp_path / "nestquest.db" + _truncated_database(db_path) + + close_calls: list[bool] = [] + original_close = NestQuestDatabase.close + + async def _spy_close(self): + close_calls.append(True) + return await original_close(self) + + monkeypatch.setattr(NestQuestDatabase, "close", _spy_close) + with pytest.raises(ConfigEntryNotReady): + _run(_async_open_database(_make_hass_mock(), db_path)) + # Corruption hit at PRAGMA time inside open(): no connection was + # adopted, so there is nothing to close and close() must NOT have + # been called (calling close on a never-opened wrapper would be + # harmless but the helper deliberately skips it). + assert close_calls == [] + + # Contrast: a failure DURING migrations (file opens fine) closes. + close_calls.clear() + valid_path = tmp_path / "migrations-fail.db" + _valid_database(valid_path) + # _valid_database's own close went through the spy; only the + # failure path's close counts from here. + close_calls.clear() + from custom_components.nestquest import migrations as migrations_mod + from custom_components.nestquest.db import NestQuestDatabase as _DB + + async def _failing(database, migrations=None): + raise sqlite3.DatabaseError("database disk image is malformed") + + monkeypatch.setattr(migrations_mod, "apply_migrations", _failing) + monkeypatch.setattr( + "custom_components.nestquest.apply_migrations", _failing + ) + with pytest.raises(ConfigEntryNotReady) as excinfo: + _run(_async_open_database(_make_hass_mock(), valid_path)) + # Exactly one close: the migrations-failure cleanup path. (The + # spy also sees the wrapper's own idempotent close if it were + # double-called — it must not be.) + assert close_calls == [True], ( + "an open connection must be closed before the error propagates" + ) + assert isinstance(excinfo.value.__cause__, sqlite3.DatabaseError) + + +# --------------------------------------------------------------------------- +# Startup through async_setup_entry (the real wiring) +# --------------------------------------------------------------------------- + + +def _make_setup(hass, registry): + def _setup(entry): + return async_setup_entry(hass, entry) + + return _setup + + +async def _setup_entry(hass, entry, registry) -> object: + entry = _wire(entry, registry) + await async_setup_entry(hass, entry) + return entry + + +async def test_setup_with_corrupt_file_raises_not_ready( + hass, make_entry, monkeypatch, tmp_path, caplog +) -> None: + from custom_components.nestquest.store import async_get_db_path + + db_path = await async_get_db_path(hass) + await _avalid_database(db_path) + data = db_path.read_bytes() + assert len(data) > 64 + db_path.write_bytes(data[: len(data) // 2]) + corrupt_bytes = db_path.read_bytes() + + entry = make_entry() + with pytest.raises(ConfigEntryNotReady): + await _setup_entry(hass, entry, hass.registry) + # File untouched, no runtime data stored. + assert db_path.read_bytes() == corrupt_bytes + assert hass.data["nestquest"].get(entry.entry_id) is None + + +async def test_setup_with_missing_file_creates_and_loads( + hass, make_entry +) -> None: + entry = await _setup_entry(hass, make_entry(), hass.registry) + database = entry.runtime_data.database + try: + row = await database.fetch_one( + f"SELECT version FROM {VERSION_TABLE}" + ) + assert row == (1,) + finally: + await database.close() + assert await async_unload_entry(hass, entry) is True + + +async def test_setup_with_valid_file_preserves_rows( + hass, make_entry +) -> None: + entry_a = await _setup_entry(hass, make_entry(), hass.registry) + database_a = entry_a.runtime_data.database + await database_a.execute( + "INSERT INTO children (display_name, created_at) VALUES (?, ?)", + ("Ada", "2026-09-14T12:00:00+00:00"), + ) + # Unload pops the runtime record AND closes the connection; a + # fresh setup for the same entry_id then reopens the file. + assert await async_unload_entry(hass, entry_a) is True + + entry_b = await _setup_entry(hass, make_entry(), hass.registry) + database_b = entry_b.runtime_data.database + assert database_b is not database_a + try: + row = await database_b.fetch_one("SELECT COUNT(*) FROM children") + assert row == (1,) + finally: + await database_b.close() + assert await async_unload_entry(hass, entry_b) is True + + +# --------------------------------------------------------------------------- +# No code path deletes or overwrites an existing database file +# --------------------------------------------------------------------------- + + +def test_package_never_unlinks_or_truncates_db_files() -> None: + """Guardrail: no code path may delete or overwrite an existing + database file. Scans the package for unlink/rmtree/write on the + DB filename constant or *.db patterns. + """ + import re + from pathlib import Path + + package = Path( + __import__( + "custom_components.nestquest", fromlist=["__file__"] + ).__file__ + ).parent + pattern = re.compile( + r"(unlink|rmtree|write_bytes|write_text|open\s*\(\s*[^)]*['\"]w)" + r"[^\n]*(nestquest\.db|\.db['\"])|" + r"(nestquest\.db|\.db['\"])[^\n]*" + r"(unlink|rmtree|write_bytes|write_text)", + re.IGNORECASE, + ) + offenders: list[str] = [] + for py in sorted(package.glob("*.py")): + if pattern.search(py.read_text()): + offenders.append(py.name) + assert offenders == [], ( + f"code paths that could delete/overwrite the database: {offenders}" + ) \ No newline at end of file diff --git a/tests/test_store.py b/tests/test_store.py index 3b20923..1934508 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -101,12 +101,20 @@ def test_executor_helper_creates_directory(tmp_path) -> None: def test_no_sqlite3_import_outside_db_module() -> None: - """sqlite3 is banned package-wide except db.py, the connection wrapper.""" + """sqlite3 is banned package-wide except two justified modules. + + db.py is the connection wrapper (all statement execution lives + there). __init__.py imports sqlite3 ONLY for its exception types + to classify corruption on startup (done-condition: corrupt file -> + ConfigEntryNotReady); it never opens a connection or executes + SQL — that is asserted separately below. + """ pkg_dir = Path(store.__file__).parent py_files = list(pkg_dir.glob("*.py")) assert py_files + allowed = {"db.py", "__init__.py"} for py_file in py_files: - if py_file.name == "db.py": + if py_file.name in allowed: continue tree = ast.parse(py_file.read_text(encoding="utf-8"), filename=str(py_file)) for node in ast.walk(tree): @@ -121,6 +129,36 @@ def test_no_sqlite3_import_outside_db_module() -> None: ) +def test_init_module_never_opens_or_executes_sqlite() -> None: + """The __init__.py sqlite3 usage is limited to (a) exception typing + for corruption classification and (b) the ONE read-only preflight + connection (mode=ro URI, SELECT-only) that detects corruption in an + existing file before the real read-write open. No read-write + connect, no cursor writes, no non-SELECT statements may appear. + """ + import re + from pathlib import Path + + init_path = ( + Path(store.__file__).parent / "__init__.py" + ).read_text(encoding="utf-8") + # The preflight connect must be the URI read-only form, and only + # SELECT statements and the integrity_check PRAGMA may run there. + assert "sqlite3.connect(uri, uri=True)" in init_path + assert "?mode=ro" in init_path + assert 'PRAGMA integrity_check' in init_path + forbidden = re.compile( + r"sqlite3\s*\.\s*connect(?!\(\s*uri\s*,)" + r"|\bconn(ection)?\s*\.\s*execute\s*\(\s*[\"'](?!SELECT\s|PRAGMA\s+integrity_check)" + r"|\bexecutemany\b|\bexecutescript\b", + re.IGNORECASE, + ) + assert forbidden.search(init_path) is None, ( + "__init__.py may use sqlite3 only for exception typing and the " + "read-only corruption preflight (SELECT + integrity_check)" + ) + + def test_no_file_creation_calls_in_store() -> None: source = Path(store.__file__).read_text(encoding="utf-8") tree = ast.parse(source, filename=str(store.__file__))