From c5cebc9f493f728e71a33acddfc5bfbc9410b5e3 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Mon, 14 Sep 2026 08:32:39 -0400 Subject: [PATCH 1/5] feat: handle missing, valid, and corrupt databases on startup (#22) --- custom_components/nestquest/__init__.py | 110 +++++++- tests/conftest.py | 14 + tests/test_dao_children.py | 59 +++- tests/test_migrations.py | 13 +- tests/test_startup_db.py | 346 ++++++++++++++++++++++++ tests/test_store.py | 33 ++- 6 files changed, 566 insertions(+), 9 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..092f53b 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,109 @@ 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", + "encrypted or malformed", +) + + +def _is_corruption_error(error: BaseException) -> bool: + """Return True when ``error`` indicates a corrupt database file. + + Matching happens on the message: sqlite3.DatabaseError is the base + of several non-corruption errors too (e.g. OperationalError), and + only the corruption spellings warrant refusing to open. + """ + message = str(error).lower() + return isinstance(error, sqlite3.DatabaseError) and any( + marker in message for marker in _CORRUPTION_MARKERS + ) + +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 + inside open() (the WAL-mode PRAGMA reads the header), while + damaged pages fail later, inside migrations. Both are classified + the same way. + """ + try: + 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: @@ -50,9 +157,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..89f7756 --- /dev/null +++ b/tests/test_startup_db.py @@ -0,0 +1,346 @@ +"""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_does_not_raise_database_error_directly(tmp_path) -> None: + """The corrupt case must surface as ConfigEntryNotReady, not crash + setup with a raw sqlite3.DatabaseError.""" + db_path = tmp_path / "nestquest.db" + _truncated_database(db_path) + try: + _run(_async_open_database(_make_hass_mock(), db_path)) + except ConfigEntryNotReady: + pass + except sqlite3.DatabaseError as err: + raise AssertionError( + f"corruption leaked as raw {type(err).__name__}: {err}" + ) from err + + +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..e570133 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,27 @@ def test_no_sqlite3_import_outside_db_module() -> None: ) +def test_init_module_never_opens_or_executes_sqlite() -> None: + """The __init__.py sqlite3 exemption is exception-typing only: no + sqlite3.connect, no .execute, no cursor usage may appear there. + """ + import re + from pathlib import Path + + init_path = ( + Path(store.__file__).parent / "__init__.py" + ).read_text(encoding="utf-8") + usage_pattern = re.compile( + r"sqlite3\s*\.\s*(connect|Cursor|Row|register|complete_statement)" + r"|\bconn(ection)?\s*\.\s*execute\b", + re.IGNORECASE, + ) + assert usage_pattern.search(init_path) is None, ( + "__init__.py must use sqlite3 exception types only, never open " + "connections or execute SQL" + ) + + 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__)) -- 2.55.0 From 0281b51676b577d40cbc1820b6a2df3b856999a5 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Mon, 14 Sep 2026 08:37:07 -0400 Subject: [PATCH 2/5] fix: read-only corruption preflight, early-return safety path, errorcode classification, sidecar snapshot test - _preflight_existing_file probes an EXISTING file with a mode=ro URI connection before the read-write open, so a corrupt file is detected BEFORE WAL mode persists or sidecars appear; the preflight is inside the same ConfigEntryNotReady classification as open()/migrations - corruption classification now prefers sqlite_errorcode (SQLITE_CORRUPT/ SQLITE_NOTADB primary-byte match, locale-independent, Python 3.11+) with message fallback including 'database disk image is malformed' - the early-return path (existing runtime record) routes through _async_open_database and reopens when db is None OR not connected - new migration-stage corruption test: valid header + damaged page 4096+, asserts main file byte-identical AND no -wal/-shm sidecars left behind; vacuous test replaced with pytest.raises form --- custom_components/nestquest/__init__.py | 78 +++++++++++++++++++++---- tests/test_startup_db.py | 48 ++++++++++++--- tests/test_store.py | 24 +++++--- 3 files changed, 122 insertions(+), 28 deletions(-) diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index 092f53b..5d9357c 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -25,21 +25,67 @@ _LOGGER = LOGGER #: "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. - Matching happens on the message: sqlite3.DatabaseError is the base - of several non-corruption errors too (e.g. OperationalError), and - only the corruption spellings warrant refusing to open. + 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 isinstance(error, sqlite3.DatabaseError) and any( - marker in message for marker in _CORRUPTION_MARKERS - ) + return any(marker in message for marker in _CORRUPTION_MARKERS) + + +def _preflight_existing_file(db_path: Path) -> None: + """Detect corruption in an EXISTING file before it is opened read-write. + + Opening read-write persists WAL mode and can create -wal/-shm + sidecars, which would modify a corrupt file before detection — + breaking the leave-untouched guarantee. This preflight reads the + file's header with a READ-ONLY connection (mode=ro URI): a corrupt + header raises there, before anything is written. Missing files + skip the preflight (they are created fresh by the real open). + """ + if not db_path.exists() or db_path.stat().st_size == 0: + return + uri = f"file:{db_path}?mode=ro" + try: + conn = sqlite3.connect(uri, uri=True) + try: + conn.execute("SELECT count(*) FROM sqlite_master").fetchone() + finally: + conn.close() + except sqlite3.DatabaseError as err: + if _is_corruption_error(err): + raise + # Read-only probing can fail for non-corruption reasons (e.g. + # a transient lock); the real open path will surface those. + _LOGGER.debug( + "NestQuest preflight read of %s failed (non-corruption): %s", + db_path, + err, + ) async def _async_open_database( hass: HomeAssistant, db_path: Path @@ -58,11 +104,14 @@ async def _async_open_database( existing database file. Corruption can surface at EITHER stage: a truncated header fails - inside open() (the WAL-mode PRAGMA reads the header), while - damaged pages fail later, inside migrations. Both are classified - the same way. + 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: + _preflight_existing_file(db_path) database = await NestQuestDatabase(hass).open(db_path) except asyncio.CancelledError: raise @@ -145,8 +194,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 diff --git a/tests/test_startup_db.py b/tests/test_startup_db.py index 89f7756..e262500 100644 --- a/tests/test_startup_db.py +++ b/tests/test_startup_db.py @@ -166,19 +166,49 @@ def test_corrupt_file_raises_not_ready_and_leaves_file_untouched( ), "the error log must name the corrupt path" -def test_corrupt_file_does_not_raise_database_error_directly(tmp_path) -> None: +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.""" + 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) - try: + with pytest.raises(ConfigEntryNotReady) as excinfo: _run(_async_open_database(_make_hass_mock(), db_path)) - except ConfigEntryNotReady: - pass - except sqlite3.DatabaseError as err: - raise AssertionError( - f"corruption leaked as raw {type(err).__name__}: {err}" - ) from err + assert isinstance(excinfo.value.__cause__, sqlite3.DatabaseError) + + +def test_migration_stage_corruption_leaves_db_and_sidecars_untouched( + tmp_path, +) -> None: + """A valid header with a corrupt LATER page is detected during + migrations, AFTER the read-write open has switched the file to WAL. + The leave-untouched guarantee must still hold byte-for-byte for + the main file AND no WAL/SHM sidecars may be left behind for the + owner to clean up. + """ + db_path = tmp_path / "nestquest.db" + _valid_database(db_path) + data = bytearray(db_path.read_bytes()) + # 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 migrations discover damage. + assert len(data) > 4200 + 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() + + with pytest.raises(ConfigEntryNotReady) as excinfo: + _run(_async_open_database(_make_hass_mock(), db_path)) + assert isinstance(excinfo.value.__cause__, sqlite3.DatabaseError) + + # 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_open_failure_closes_connection_when_opened( diff --git a/tests/test_store.py b/tests/test_store.py index e570133..31e6c66 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -130,8 +130,11 @@ def test_no_sqlite3_import_outside_db_module() -> None: def test_init_module_never_opens_or_executes_sqlite() -> None: - """The __init__.py sqlite3 exemption is exception-typing only: no - sqlite3.connect, no .execute, no cursor usage may appear there. + """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 @@ -139,14 +142,19 @@ def test_init_module_never_opens_or_executes_sqlite() -> None: init_path = ( Path(store.__file__).parent / "__init__.py" ).read_text(encoding="utf-8") - usage_pattern = re.compile( - r"sqlite3\s*\.\s*(connect|Cursor|Row|register|complete_statement)" - r"|\bconn(ection)?\s*\.\s*execute\b", + # The preflight connect must be the URI read-only form, and only + # SELECT statements may run through it. + assert "sqlite3.connect(uri, uri=True)" in init_path + assert "?mode=ro" in init_path + forbidden = re.compile( + r"sqlite3\s*\.\s*connect(?!\(\s*uri\s*,)" + r"|\bconn(ection)?\s*\.\s*execute\s*\(\s*[\"'](?!SELECT)" + r"|\bexecutemany\b|\bexecutescript\b", re.IGNORECASE, ) - assert usage_pattern.search(init_path) is None, ( - "__init__.py must use sqlite3 exception types only, never open " - "connections or execute SQL" + assert forbidden.search(init_path) is None, ( + "__init__.py may use sqlite3 only for exception typing and the " + "read-only corruption preflight" ) -- 2.55.0 From 5db49b3d29ab4fd1422775bd017885b0ba9d80b3 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Mon, 14 Sep 2026 08:39:58 -0400 Subject: [PATCH 3/5] fix: whole-file read-only integrity preflight on the executor, rollback-journal fixture - preflight now runs PRAGMA integrity_check (whole-file validation, not just sqlite_master) over a mode=ro connection, dispatched via hass.async_add_executor_job so the event loop never blocks - migration-stage corruption test fixture is now a ROLLBACK-JOURNAL file created with plain sqlite3 (default journal mode), so the WAL transition the guardrail protects against actually happens in the test instead of being masked by an already-WAL fixture --- custom_components/nestquest/__init__.py | 36 ++++++++++++++++++------- tests/test_startup_db.py | 26 +++++++++++++++--- 2 files changed, 49 insertions(+), 13 deletions(-) diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index 5d9357c..8e7a341 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -57,25 +57,41 @@ def _is_corruption_error(error: BaseException) -> bool: return any(marker in message for marker in _CORRUPTION_MARKERS) -def _preflight_existing_file(db_path: Path) -> None: +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 and can create -wal/-shm - sidecars, which would modify a corrupt file before detection — - breaking the leave-untouched guarantee. This preflight reads the - file's header with a READ-ONLY connection (mode=ro URI): a corrupt - header raises there, before anything is written. Missing files - skip the preflight (they are created fresh by the real open). + 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 URI, + PRAGMA integrity_check, run on the executor like every other DB + call): corruption anywhere fails there, before anything is + written. Missing/empty files skip the preflight (they are created + fresh by the real open). """ if not db_path.exists() or db_path.stat().st_size == 0: return uri = f"file:{db_path}?mode=ro" - try: + + def _probe() -> None: conn = sqlite3.connect(uri, uri=True) try: - conn.execute("SELECT count(*) FROM sqlite_master").fetchone() + row = conn.execute("PRAGMA integrity_check").fetchone() finally: conn.close() + verdict = row[0] if row else "missing" + if verdict != "ok": + raise sqlite3.DatabaseError( + f"integrity_check on read-only probe failed: {verdict}" + ) + + 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: if _is_corruption_error(err): raise @@ -111,7 +127,7 @@ async def _async_open_database( same way. """ try: - _preflight_existing_file(db_path) + await _preflight_existing_file(hass, db_path) database = await NestQuestDatabase(hass).open(db_path) except asyncio.CancelledError: raise diff --git a/tests/test_startup_db.py b/tests/test_startup_db.py index e262500..04e650d 100644 --- a/tests/test_startup_db.py +++ b/tests/test_startup_db.py @@ -185,14 +185,34 @@ def test_migration_stage_corruption_leaves_db_and_sidecars_untouched( The leave-untouched guarantee must still hold byte-for-byte for the main file AND no WAL/SHM sidecars may be left behind for the owner to clean up. + + 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. """ db_path = tmp_path / "nestquest.db" - _valid_database(db_path) + 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 migrations discover damage. - assert len(data) > 4200 + # 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)) -- 2.55.0 From 5b5aa7c789b3d37adaf77613d27469814ff4d6a9 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Mon, 14 Sep 2026 08:43:08 -0400 Subject: [PATCH 4/5] fix: non-ok integrity verdicts are corruption, escaped file URIs, proven no-WAL-transition test - a non-ok integrity_check verdict raises DatabaseError carrying the NOTADB marker so it classifies as corruption unconditionally; ANY DatabaseError escaping the probe propagates (no more swallowing structural problems as non-corruption) - file URI built via Path.resolve().as_uri() so #/?/% in paths cannot mis-target the probe - migration-stage test now spies on NestQuestDatabase.open and asserts the read-write open was NEVER reached: the whole-file preflight rejecting corruption before the WAL transition is the mechanism under test, and the byte-identical + no-sidecar assertions prove it - store guard permits SELECT + PRAGMA integrity_check on the ro-conn --- custom_components/nestquest/__init__.py | 27 +++++++++++++------- tests/test_startup_db.py | 33 +++++++++++++++++++------ tests/test_store.py | 7 +++--- 3 files changed, 48 insertions(+), 19 deletions(-) diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index 8e7a341..a3dcd24 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -74,7 +74,9 @@ async def _preflight_existing_file( """ if not db_path.exists() or db_path.stat().st_size == 0: return - uri = f"file:{db_path}?mode=ro" + # Properly escape the path: '#'/'?'/'%' in a directory or file name + # would otherwise be parsed as URI fragments/parameters/escapes. + uri = db_path.resolve().as_uri() + "?mode=ro" def _probe() -> None: conn = sqlite3.connect(uri, uri=True) @@ -84,8 +86,12 @@ async def _preflight_existing_file( 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 on read-only probe failed: {verdict}" + f"integrity_check verdict {verdict!r} on read-only " + f"probe: file is not a database" ) try: @@ -93,15 +99,18 @@ async def _preflight_existing_file( # 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 - # Read-only probing can fail for non-corruption reasons (e.g. - # a transient lock); the real open path will surface those. - _LOGGER.debug( - "NestQuest preflight read of %s failed (non-corruption): %s", - db_path, - err, - ) + 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 diff --git a/tests/test_startup_db.py b/tests/test_startup_db.py index 04e650d..f9a222d 100644 --- a/tests/test_startup_db.py +++ b/tests/test_startup_db.py @@ -178,18 +178,17 @@ def test_corrupt_file_raises_not_ready_never_raw_error(tmp_path) -> None: def test_migration_stage_corruption_leaves_db_and_sidecars_untouched( - tmp_path, + tmp_path, monkeypatch ) -> None: - """A valid header with a corrupt LATER page is detected during - migrations, AFTER the read-write open has switched the file to WAL. - The leave-untouched guarantee must still hold byte-for-byte for - the main file AND no WAL/SHM sidecars may be left behind for the - owner to clean up. + """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. + 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) @@ -219,10 +218,30 @@ def test_migration_stage_corruption_leaves_db_and_sidecars_untouched( 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, diff --git a/tests/test_store.py b/tests/test_store.py index 31e6c66..1934508 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -143,18 +143,19 @@ def test_init_module_never_opens_or_executes_sqlite() -> None: 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 may run through it. + # 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)" + 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" + "read-only corruption preflight (SELECT + integrity_check)" ) -- 2.55.0 From c3e7c0d3d6d47e82dc096eadabe796a5af79c32c Mon Sep 17 00:00:00 2001 From: Joshua King Date: Mon, 14 Sep 2026 08:45:48 -0400 Subject: [PATCH 5/5] fix: immutable=1 probe URI (no sidecar creation guaranteed), fs work inside executor job, WAL-header corruption test case - probe URI adds immutable=1: SQLite's documented guarantee that the read-only connection performs no writes and creates no -wal/-shm sidecars, even against a WAL-header database - exists/stat/resolve and URI construction moved inside the executor job with the probe (no event-loop disk I/O) - new WAL-header corruption test asserts byte-identical file and no -shm creation from the probe --- custom_components/nestquest/__init__.py | 30 ++++++++++++++--------- tests/test_startup_db.py | 32 +++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 11 deletions(-) diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index a3dcd24..ac2e77d 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -66,19 +66,27 @@ async def _preflight_existing_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 URI, - PRAGMA integrity_check, run on the executor like every other DB - call): corruption anywhere fails there, before anything is - written. Missing/empty files skip the preflight (they are created - fresh by the real open). + 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. """ - 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. - uri = db_path.resolve().as_uri() + "?mode=ro" - 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() diff --git a/tests/test_startup_db.py b/tests/test_startup_db.py index f9a222d..5b476f0 100644 --- a/tests/test_startup_db.py +++ b/tests/test_startup_db.py @@ -250,6 +250,38 @@ def test_migration_stage_corruption_leaves_db_and_sidecars_untouched( 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: -- 2.55.0