Files
hermes-agent/tests/hermes_state/test_readonly_fts_decode_error.py
teknium1 d10bb2ab6f test: make tests/ mirror the source tree; drop issue numbers from filenames
`scripts/run_tests.sh tests/<dir>/` is how a change gets its regression
coverage run, so a test filed under the wrong directory is a test nobody
runs when that code changes. Two kinds of drift had accumulated.

Parallel directories for one source package, folded into the mirror:
  tests/acp        -> tests/acp_adapter   (its __init__/conftest move with it)
  tests/cli        -> tests/hermes_cli    (prompt_toolkit fixture merged into
                                           hermes_cli/conftest.py)
  tests/run_agent  -> tests/agent         (backoff fixture becomes
                                           agent/conftest.py)
  tests/relay      -> tests/gateway/relay
  tests/state      -> tests/hermes_state

246 loose files at tests/ root, routed by the package they import/patch:
hermes_cli, hermes_state, agent, gateway, tools, plugins, tui_gateway, cron.
Installer and desktop-update script tests go to tests/scripts/{install,
desktop_update}/. 43 tests of root-level modules (batch_runner, utils,
hermes_constants, packaging) stay at the root.

Filenames drop their issue numbers (95 files: test_89315_x.py -> test_x.py);
the number stays in the module docstring where it has context.

Collisions: test_cli_skin_integration.py existed in both tests/ and tests/cli
with different subsets — merged into one (10 tests, all kept);
run_agent/test_pre_compress_memory_context.py -> agent/..._handoff.py;
tests/test_account_usage.py -> agent/test_account_usage_fetch.py;
tests/test_web_server.py -> hermes_cli/test_web_server_ws_ping.py.
Deleted: test_minisweagent_path.py (empty since PR #2804),
test_model_picker_scroll.py (tested a private copy of the logic, imported
nothing), test_process_loop_event_loop_warning.py (asserted asyncio behaviour,
imported nothing from Hermes).

Repo-root path arithmetic (Path(__file__).parents[N], dirname chains) is
bumped for the 202 files that changed depth and verified by evaluating every
such expression against the new location. classify_changes' desktop-updater
lane prefix, tests-os.yml's ignore glob and every in-tree path comment follow
the moves. tests/test_tests_tree_layout.py keeps the tree from drifting back.
2026-09-13 09:18:02 -07:00

139 lines
5.8 KiB
Python
Raw Blame History

"""Test that read-only SessionDB survives invalid UTF-8 in FTS content (issue #98924)."""
import sqlite3
from pathlib import Path
import pytest
from hermes_state import SessionDB
def _write_invalid_utf8_row(db_path: Path) -> None:
"""Inject a non-UTF-8 byte into an existing message row via low-level API.
Python's high-level sqlite3.Connection will not let us write invalid
UTF-8 into a TEXT column — it decodes and re-encodes. We use the
connection's lower-level interface to force it through.
"""
# CAST(x'61625F816364' AS TEXT) → 'ab_<62>cd' with 0x81 at position 3
conn = sqlite3.connect(str(db_path))
try:
conn.execute("PRAGMA foreign_keys = OFF")
# Force the bytes through by overwriting an existing row id.
conn.execute("UPDATE messages SET content = CAST(x'61625F816364' AS TEXT) WHERE id = 1")
conn.commit()
finally:
conn.close()
class TestReadOnlyFTSDecodeError:
def test_read_only_open_survives_invalid_utf8_in_fts_content(self, tmp_path):
"""Non-UTF-8 bytes in messages.content don't kill the read-only open.
Regression test for issue #98924: a developer's state.db contained a
byte 0x81 in a messages row. External-content FTS5 (v23 layout) reads
that column when building a result set. On some Python/SQLite builds
the decode failure surfaces as UnicodeDecodeError; on others as
OperationalError("Could not decode to UTF-8 column ...").
The old code caught only sqlite3.OperationalError and would re-raise
any other exception. UnicodeDecodeError (a ValueError, not an
sqlite3.Error subclass) therefore bypassed the guard and killed the
read-only connection, taking down every read endpoint (GET /api/sessions).
The fix catches (sqlite3.OperationalError, UnicodeDecodeError) and
treats the decode error the same as "FTS unavailable" — the index is
degraded but the connection stays open. Search may return less or fail
on that corrupted row, but writes and non-FTS reads keep working.
"""
db_path = tmp_path / "state.db"
# Create a fresh DB with v23 external-content FTS.
writable = SessionDB(db_path=db_path)
writable.create_session("decode-test", source="cli")
writable.append_message("decode-test", role="user", content="valid text")
writable.close()
# Insert a row with invalid UTF-8 through the sqlite3 CLI.
_write_invalid_utf8_row(db_path)
# Also trigger a rebuild so the invalid bytes are in the FTS index.
conn = sqlite3.connect(str(db_path))
conn.execute("INSERT INTO messages_fts(messages_fts) VALUES('rebuild')")
conn.commit()
conn.close()
# The shipped regression: SessionDB(read_only=True) should NOT raise.
# Before the fix, this raised UnicodeDecodeError from _fts_table_probe.
read_only = SessionDB(db_path=db_path, read_only=True)
try:
# Verify the connection opened — _fts_table_probe didn't kill init.
assert read_only._conn is not None
finally:
read_only.close()
# Also verify that the index degraded: _fts_enabled should be None or
# False, not True, because the corrupt content prevents probing.
read_only2 = SessionDB(db_path=db_path, read_only=True)
try:
# The index is broken; the store itself must stay accessible.
assert read_only2._conn is not None
finally:
read_only2.close()
def _corrupt_schema_with_raw_bytes(db_path: Path) -> None:
"""Rewrite an FTS vtable's sqlite_master row with invalid UTF-8 bytes.
This is the fixture that actually reproduces #98924 on main: pysqlite
raises a bare UnicodeDecodeError at execute() time when SQLite's own
error/schema text carries raw non-UTF-8 file bytes. (Invalid UTF-8 in
messages.content alone does NOT make the LIMIT 0 probe raise.)
"""
conn = sqlite3.connect(str(db_path), isolation_level=None)
try:
conn.execute("PRAGMA writable_schema=ON")
badname = b"tbl_\x81\x82"
conn.execute(
"UPDATE sqlite_master SET name=CAST(? AS TEXT), "
"tbl_name=CAST(? AS TEXT), sql=CAST(? AS TEXT) "
"WHERE name='messages_fts_trigram'",
(badname, badname, b"CREATE GARBAGE \x81\x82"),
)
ver = conn.execute("PRAGMA schema_version").fetchone()[0]
conn.execute(f"PRAGMA schema_version = {ver + 1}")
conn.execute("PRAGMA writable_schema=OFF")
finally:
conn.close()
class TestSchemaBytesDecodeError:
def test_read_only_open_survives_raw_bytes_in_schema(self, tmp_path):
"""Genuine on-main repro of #98924: schema-area corruption whose raw
bytes reach pysqlite's error-message decode. Before the fix the probe
re-raised the resulting UnicodeDecodeError and killed read-only init.
"""
db_path = tmp_path / "state.db"
writable = SessionDB(db_path=db_path)
writable.create_session("schema-bytes", source="cli")
writable.append_message("schema-bytes", role="user", content="hello")
writable.close()
_corrupt_schema_with_raw_bytes(db_path)
# Precondition: the raw probe really raises UnicodeDecodeError on
# this fixture (guards the test against silently stopping to
# exercise the bug on future SQLite versions).
raw = sqlite3.connect(str(db_path))
try:
with pytest.raises(UnicodeDecodeError):
raw.execute("SELECT * FROM messages_fts LIMIT 0")
finally:
raw.close()
read_only = SessionDB(db_path=db_path, read_only=True)
try:
assert read_only._conn is not None
finally:
read_only.close()