Files
learningcircuit--local-deep…/tests/database/test_post_login_settings_atomicity.py
T
wehub-resource-sync 7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

348 lines
12 KiB
Python

"""
Regression tests for the post-login settings atomicity invariant.
Three load-bearing properties are locked in here:
1. On failure mid-write, the DB rolls back cleanly — no partial rows,
no orphaned `app.version`. The next login retries from a clean
state instead of entering the sticky loop
(commit 621d8d0d — fix(auth): atomic settings reload + app.version
update on login).
2. On success, both the defaults import AND `app.version` persist
together — the invariant that makes
`db_version_matches_package()` useful.
3. `engine.dispose()` on a busy engine does NOT break a thread holding
a checked-out connection. This is the SA 2.0 contract
(`QueuePool.dispose` drains only idle queue entries,
`Engine.dispose` calls `pool.recreate()`, checked-out connections
keep working) and we lock it in so a future SA upgrade cannot
silently change it. This test makes PR #3487's "dispose orphans
checked-out" mechanism claim falsifiable — if it ever starts being
true for our SQLCipher+WAL path, this test fails.
"""
import sys
import tempfile
import threading
from pathlib import Path
import pytest
from sqlalchemy.orm import sessionmaker
sys.path.insert(0, str(Path(__file__).parent.parent.parent.resolve()))
from local_deep_research.database.encrypted_db import DatabaseManager
from local_deep_research.database.models import Setting
from local_deep_research.settings.manager import SettingsManager
@pytest.fixture
def temp_data_dir():
"""Per-test temporary directory for encrypted databases."""
with tempfile.TemporaryDirectory() as tmpdir:
yield Path(tmpdir)
@pytest.fixture
def db_manager(temp_data_dir, monkeypatch):
"""DatabaseManager pointed at a temp directory.
Production QueuePool is used (not StaticPool) so the dispose-vs-
checkout test exercises the real code path.
"""
monkeypatch.setattr(
"local_deep_research.database.encrypted_db.get_data_directory",
lambda: temp_data_dir,
)
monkeypatch.delenv("TESTING", raising=False)
manager = DatabaseManager()
yield manager
for username in list(manager.connections.keys()):
try:
manager.close_user_database(username)
except Exception:
pass
@pytest.fixture
def user_engine(db_manager):
"""Fresh per-test SQLCipher engine for a unique user.
After `create_user_database`, the engine is already populated by
`initialize_database` with the full defaults set + `app.version`.
Tests that need a pre-import state delete the relevant rows in
their arrange step.
"""
username = "atomicity_user"
password = "AtomicityPass123!"
engine = db_manager.create_user_database(username, password)
yield username, password, engine
def _has_setting(engine, key: str) -> bool:
Session = sessionmaker(bind=engine)
with Session() as s:
return s.query(Setting).filter(Setting.key == key).first() is not None
def _delete_keys(engine, keys):
Session = sessionmaker(bind=engine)
with Session() as s:
s.query(Setting).filter(Setting.key.in_(list(keys))).delete(
synchronize_session=False
)
s.commit()
def _run_post_login_atomic_block(engine, raise_after_stage: bool = False):
"""Mirror the atomic block in `_perform_post_login_tasks` step 1.
We call the SettingsManager methods directly against a real engine
instead of routing through `get_user_db_session` so the test does
not depend on Flask session state. The transaction scope is
identical: one session, one terminal commit.
Args:
engine: SQLCipher engine for the user DB.
raise_after_stage: If True, raise after load_from_defaults_file
has staged rows but before the terminal commit. This models
a mid-write crash / thread death / dispose-triggered error
whose rollback must leave the DB in a clean pre-write state.
"""
Session = sessionmaker(bind=engine)
with Session() as db_session:
settings_manager = SettingsManager(db_session)
settings_manager.load_from_defaults_file(commit=False, overwrite=False)
if raise_after_stage:
raise RuntimeError("simulated mid-write failure")
settings_manager.update_db_version(commit=False)
db_session.commit()
class TestPostLoginSettingsAtomicity:
"""The invariant fixed by commit 621d8d0d must stay fixed."""
def test_atomic_block_restores_deleted_keys_and_app_version(
self, user_engine
):
"""
Happy path: if the pre-login state is missing `app.version` and
some defaults (simulating a version-mismatch + manual-cleanup
scenario), the atomic block restores everything in one commit.
"""
_, _, engine = user_engine
probe_keys = {"app.version", "app.debug"}
_delete_keys(engine, probe_keys)
for k in probe_keys:
assert not _has_setting(engine, k), (
f"test precondition: {k} deleted"
)
_run_post_login_atomic_block(engine)
for k in probe_keys:
assert _has_setting(engine, k), (
f"{k} must be restored after a successful atomic block"
)
def test_mid_write_failure_rolls_back_to_pre_write_state(self, user_engine):
"""
Sticky-loop regression guard. Without atomicity the pre-621d8d0d
path committed the defaults import first and then tried to
commit `app.version` separately; any failure between the two
commits left `app.version` unwritten while staged rows had
already landed — every subsequent login re-ran the bulk
insert.
With the atomic block, a failure between staging and commit
rolls back ALL staged changes — the probe keys deleted below
remain deleted, and `app.version` is still absent. Next login
retries from an unchanged pre-write state.
"""
_, _, engine = user_engine
probe_keys = {"app.version", "app.debug"}
_delete_keys(engine, probe_keys)
for k in probe_keys:
assert not _has_setting(engine, k)
with pytest.raises(RuntimeError, match="simulated mid-write failure"):
_run_post_login_atomic_block(engine, raise_after_stage=True)
for k in probe_keys:
assert not _has_setting(engine, k), (
f"{k} was deleted before the atomic block; a mid-write "
f"failure must roll back all staged changes and leave "
f"it deleted. If it exists now, the two-commit split "
f"has regressed and the sticky loop is back."
)
_run_post_login_atomic_block(engine)
for k in probe_keys:
assert _has_setting(engine, k)
def test_post_login_routes_uses_commit_false_for_both_calls(self):
"""
Structural guard: lock in that the post-login task body calls
`load_from_defaults_file` and `update_db_version` with
`commit=False` and emits a single terminal `db_session.commit()`.
Any refactor that regresses to the two-commit form will fail
this test before production sees a sticky loop.
Inspects `_perform_post_login_tasks_body` (not the decorated
wrapper) because #3489 split the function into a thin
try/except wrapper + body for daemon-thread exception logging.
The atomic block lives in the body.
"""
import inspect
from local_deep_research.web.auth import routes
src = inspect.getsource(routes._perform_post_login_tasks_body)
assert "load_from_defaults_file(" in src
assert "update_db_version(commit=False)" in src, (
"update_db_version must be called with commit=False so the "
"atomic block controls the terminal commit"
)
lfd_idx = src.index("load_from_defaults_file(")
lfd_tail = src[lfd_idx : lfd_idx + 200]
assert "commit=False" in lfd_tail, (
"load_from_defaults_file must be called with commit=False "
"so the atomic block controls the terminal commit"
)
assert "db_session.commit()" in src, (
"the atomic block must end with a single db_session.commit()"
)
class TestCheckedOutConnectionSurvivesDispose:
"""Lock in the SA 2.0 contract that PR #3487 misread."""
@pytest.mark.parametrize("iteration", range(20))
def test_checked_out_session_completes_after_dispose(
self, user_engine, iteration
):
"""
Mid-transaction dispose from another thread MUST NOT break the
writer's session. SA 2.0 `QueuePool.dispose` drains only idle
entries and `Engine.dispose` calls `pool.recreate()` — a thread
holding a checked-out connection keeps using it until return.
20 iterations surface races.
"""
_, _, engine = user_engine
key = f"atomicity.test.survival.{iteration}"
writer_ready = threading.Event()
main_disposed = threading.Event()
writer_result = {}
def writer():
Session = sessionmaker(bind=engine)
try:
with Session() as session:
session.add(
Setting(
key=key,
value=f"value_{iteration}",
name=key,
type="APP",
visible=False,
editable=False,
)
)
session.flush()
writer_ready.set()
if not main_disposed.wait(timeout=5.0):
writer_result["error"] = (
"main thread did not signal dispose within 5s"
)
return
session.commit()
writer_result["ok"] = True
except Exception as exc:
writer_result["error"] = repr(exc)
t = threading.Thread(target=writer, daemon=True)
t.start()
assert writer_ready.wait(timeout=5.0), (
f"writer did not start; result={writer_result}"
)
engine.dispose()
main_disposed.set()
t.join(timeout=10.0)
assert not t.is_alive(), "writer thread did not finish"
assert writer_result.get("ok") is True, (
"engine.dispose() must NOT break a thread holding a "
"checked-out connection — if this fires, either SA 2.0 "
"semantics changed or SQLCipher introduced a hook that "
"violates them. Error: "
f"{writer_result.get('error')}"
)
Session = sessionmaker(bind=engine)
with Session() as s:
row = s.query(Setting).filter(Setting.key == key).first()
assert row is not None, (
"writer's committed row must be readable from a fresh "
"session after dispose"
)
assert row.value == f"value_{iteration}"
def test_dispose_without_checked_out_connections_is_noop_for_clients(
self, user_engine
):
"""Sanity: dispose on an idle engine leaves subsequent sessions
working normally."""
_, _, engine = user_engine
Session = sessionmaker(bind=engine)
with Session() as s:
s.add(
Setting(
key="atomicity.test.pre_dispose",
value="before",
name="atomicity.test.pre_dispose",
type="APP",
visible=False,
editable=False,
)
)
s.commit()
engine.dispose()
with Session() as s:
row = (
s.query(Setting)
.filter(Setting.key == "atomicity.test.pre_dispose")
.first()
)
assert row is not None
assert row.value == "before"
s.add(
Setting(
key="atomicity.test.post_dispose",
value="after",
name="atomicity.test.post_dispose",
type="APP",
visible=False,
editable=False,
)
)
s.commit()
with Session() as s:
row = (
s.query(Setting)
.filter(Setting.key == "atomicity.test.post_dispose")
.first()
)
assert row is not None
assert row.value == "after"