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
348 lines
12 KiB
Python
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"
|