mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-08-05 02:45:28 +00:00
522 lines
17 KiB
Python
522 lines
17 KiB
Python
"""Regressions for process-safe email-account default mutations.
|
|
|
|
The file-backed SQLite fixture uses a fresh connection for every Session.
|
|
That exercises the same database lock boundary used by separate web workers,
|
|
rather than relying on an in-process Python lock.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import sys
|
|
import threading
|
|
import types
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest import mock
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
from sqlalchemy import create_engine, create_mock_engine, text
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.orm import sessionmaker
|
|
from sqlalchemy.pool import NullPool
|
|
|
|
|
|
@pytest.fixture
|
|
def account_db(tmp_path, monkeypatch):
|
|
from core import database as core_db
|
|
|
|
engine = create_engine(
|
|
f"sqlite:///{tmp_path / 'accounts.db'}",
|
|
connect_args={"check_same_thread": False, "timeout": 5},
|
|
poolclass=NullPool,
|
|
)
|
|
core_db.Base.metadata.create_all(engine)
|
|
factory = sessionmaker(
|
|
bind=engine,
|
|
autocommit=False,
|
|
autoflush=False,
|
|
)
|
|
monkeypatch.setattr(core_db, "SessionLocal", factory)
|
|
yield factory
|
|
engine.dispose()
|
|
|
|
|
|
def _endpoint(method, path):
|
|
from routes import email_routes
|
|
|
|
with mock.patch.object(email_routes, "_start_poller"):
|
|
router = email_routes.setup_email_routes()
|
|
for route in router.routes:
|
|
if route.path == path and method in getattr(route, "methods", set()):
|
|
return route.endpoint
|
|
raise AssertionError(f"email route not found: {method} {path}")
|
|
|
|
|
|
def _named_endpoint(router, name):
|
|
for route in router.routes:
|
|
if getattr(getattr(route, "endpoint", None), "__name__", "") == name:
|
|
return route.endpoint
|
|
raise AssertionError(f"route not found: {name}")
|
|
|
|
|
|
def _seed_account(factory, account_id, owner, *, is_default=False, enabled=True):
|
|
from core.database import EmailAccount
|
|
|
|
db = factory()
|
|
try:
|
|
db.add(
|
|
EmailAccount(
|
|
id=account_id,
|
|
owner=owner,
|
|
name=account_id,
|
|
is_default=is_default,
|
|
enabled=enabled,
|
|
)
|
|
)
|
|
db.commit()
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def _rows(factory):
|
|
from core.database import EmailAccount
|
|
|
|
db = factory()
|
|
try:
|
|
return [
|
|
(row.id, row.owner, bool(row.is_default))
|
|
for row in db.query(EmailAccount).order_by(EmailAccount.id).all()
|
|
]
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def _install_lock_pause(monkeypatch, paused_thread_name):
|
|
"""Pause one worker after acquisition and observe another waiting."""
|
|
from routes import email_routes
|
|
|
|
real_lock = email_routes._lock_email_account_owner_mutation
|
|
first_acquired = threading.Event()
|
|
release_first = threading.Event()
|
|
contender_attempted = threading.Event()
|
|
contender_acquired = threading.Event()
|
|
|
|
def controlled_lock(db, owner):
|
|
is_first = threading.current_thread().name == paused_thread_name
|
|
if not is_first:
|
|
contender_attempted.set()
|
|
real_lock(db, owner)
|
|
if is_first:
|
|
first_acquired.set()
|
|
assert release_first.wait(5), "timed out releasing first mutation"
|
|
else:
|
|
contender_acquired.set()
|
|
|
|
monkeypatch.setattr(
|
|
email_routes,
|
|
"_lock_email_account_owner_mutation",
|
|
controlled_lock,
|
|
)
|
|
return first_acquired, release_first, contender_attempted, contender_acquired
|
|
|
|
|
|
def test_concurrent_first_account_creates_choose_one_default(account_db, monkeypatch):
|
|
create_account = _endpoint("POST", "/api/email/accounts")
|
|
first_acquired, release_first, attempted, acquired = _install_lock_pause(
|
|
monkeypatch, "first-account"
|
|
)
|
|
results = {}
|
|
|
|
def create(name):
|
|
results[name] = asyncio.run(
|
|
create_account({"name": name, "is_default": False}, owner="alice")
|
|
)
|
|
|
|
first = threading.Thread(target=create, args=("First",), name="first-account")
|
|
second = threading.Thread(target=create, args=("Second",), name="second-account")
|
|
first.start()
|
|
assert first_acquired.wait(5)
|
|
second.start()
|
|
assert attempted.wait(5)
|
|
assert not acquired.wait(0.1), "second session bypassed the database mutation lock"
|
|
|
|
release_first.set()
|
|
first.join(5)
|
|
second.join(5)
|
|
|
|
assert not first.is_alive()
|
|
assert not second.is_alive()
|
|
assert results["First"]["ok"] is True
|
|
assert results["Second"]["ok"] is True
|
|
defaults = [row for row in _rows(account_db) if row[2]]
|
|
assert [(row[1], row[2]) for row in defaults] == [("alice", True)]
|
|
assert len(defaults) == 1
|
|
|
|
|
|
def test_delete_promotion_and_set_default_are_one_serial_transition(
|
|
account_db, monkeypatch
|
|
):
|
|
from sqlalchemy.orm import Session as OrmSession
|
|
|
|
_seed_account(account_db, "alice-a", "alice", is_default=True)
|
|
_seed_account(account_db, "alice-b", "alice")
|
|
_seed_account(account_db, "alice-c", "alice")
|
|
_seed_account(account_db, "bob-a", "bob", is_default=True)
|
|
|
|
delete_account = _endpoint("DELETE", "/api/email/accounts/{account_id}")
|
|
set_default = _endpoint("POST", "/api/email/accounts/{account_id}/set-default")
|
|
first_acquired, release_first, attempted, acquired = _install_lock_pause(
|
|
monkeypatch, "delete-default"
|
|
)
|
|
delete_commit_finished = threading.Event()
|
|
release_delete_after_commit = threading.Event()
|
|
real_commit = OrmSession.commit
|
|
results = {}
|
|
|
|
def pause_after_delete_commit(session):
|
|
real_commit(session)
|
|
if (
|
|
threading.current_thread().name == "delete-default"
|
|
and not delete_commit_finished.is_set()
|
|
):
|
|
delete_commit_finished.set()
|
|
assert release_delete_after_commit.wait(5), (
|
|
"timed out releasing delete after its first commit"
|
|
)
|
|
|
|
monkeypatch.setattr(OrmSession, "commit", pause_after_delete_commit)
|
|
|
|
def delete_old_default():
|
|
results["delete"] = asyncio.run(
|
|
delete_account("alice-a", owner="alice")
|
|
)
|
|
|
|
def select_new_default():
|
|
results["set"] = asyncio.run(
|
|
set_default("alice-c", owner="alice")
|
|
)
|
|
|
|
delete_thread = threading.Thread(target=delete_old_default, name="delete-default")
|
|
set_thread = threading.Thread(target=select_new_default, name="set-default")
|
|
delete_thread.start()
|
|
assert first_acquired.wait(5)
|
|
set_thread.start()
|
|
assert attempted.wait(5)
|
|
assert not acquired.wait(0.1), "set-default bypassed the delete transaction"
|
|
|
|
release_first.set()
|
|
assert delete_commit_finished.wait(5)
|
|
# The deletion transaction has committed. Let the contender complete
|
|
# before the deleting handler can continue: if promotion were still a
|
|
# second commit, it would now run after set-default and recreate two
|
|
# defaults deterministically.
|
|
assert acquired.wait(5)
|
|
set_thread.join(5)
|
|
release_delete_after_commit.set()
|
|
delete_thread.join(5)
|
|
|
|
assert not delete_thread.is_alive()
|
|
assert not set_thread.is_alive()
|
|
assert results == {"delete": {"ok": True}, "set": {"ok": True}}
|
|
assert _rows(account_db) == [
|
|
("alice-b", "alice", False),
|
|
("alice-c", "alice", True),
|
|
("bob-a", "bob", True),
|
|
]
|
|
|
|
|
|
def test_upgrade_normalizes_legacy_defaults_and_installs_unique_index(
|
|
tmp_path, monkeypatch
|
|
):
|
|
"""A pre-index schema upgrades without requiring newer account columns."""
|
|
from core import database as core_db
|
|
|
|
engine = create_engine(
|
|
f"sqlite:///{tmp_path / 'legacy-accounts.db'}",
|
|
connect_args={"check_same_thread": False},
|
|
poolclass=NullPool,
|
|
)
|
|
try:
|
|
with engine.begin() as conn:
|
|
conn.execute(text("""
|
|
CREATE TABLE email_accounts (
|
|
id VARCHAR PRIMARY KEY,
|
|
owner VARCHAR,
|
|
name VARCHAR NOT NULL,
|
|
is_default BOOLEAN NOT NULL,
|
|
enabled BOOLEAN NOT NULL,
|
|
created_at DATETIME,
|
|
updated_at DATETIME
|
|
)
|
|
"""))
|
|
conn.execute(text("""
|
|
INSERT INTO email_accounts
|
|
(id, owner, name, is_default, enabled, created_at, updated_at)
|
|
VALUES
|
|
('legacy-old', NULL, 'Old', 1, 1, '2024-01-01', '2024-01-01'),
|
|
('legacy-new', '', 'New', 1, 1, '2025-01-01', '2025-01-01')
|
|
"""))
|
|
|
|
monkeypatch.setattr(core_db, "engine", engine)
|
|
core_db._migrate_email_account_default_invariant()
|
|
core_db._migrate_email_account_default_invariant() # idempotent replay
|
|
|
|
with engine.connect() as conn:
|
|
defaults = conn.execute(text("""
|
|
SELECT id FROM email_accounts
|
|
WHERE is_default IS TRUE
|
|
ORDER BY id
|
|
""")).scalars().all()
|
|
index_names = {
|
|
row[1] for row in conn.execute(text("PRAGMA index_list(email_accounts)"))
|
|
}
|
|
assert defaults == ["legacy-old"]
|
|
assert core_db._EMAIL_ACCOUNT_DEFAULT_INDEX in index_names
|
|
|
|
with pytest.raises(IntegrityError):
|
|
with engine.begin() as conn:
|
|
conn.execute(text("""
|
|
INSERT INTO email_accounts
|
|
(id, owner, name, is_default, enabled, created_at, updated_at)
|
|
VALUES
|
|
('legacy-third', NULL, 'Third', 1, 1, '2026-01-01', '2026-01-01')
|
|
"""))
|
|
finally:
|
|
engine.dispose()
|
|
|
|
|
|
def test_concurrent_legacy_seed_is_one_locked_transaction(
|
|
tmp_path, monkeypatch, caplog
|
|
):
|
|
from core import database as core_db
|
|
|
|
engine = create_engine(
|
|
f"sqlite:///{tmp_path / 'seed-accounts.db'}",
|
|
connect_args={"check_same_thread": False, "timeout": 5},
|
|
poolclass=NullPool,
|
|
)
|
|
core_db.Base.metadata.create_all(engine)
|
|
factory = sessionmaker(bind=engine, autocommit=False, autoflush=False)
|
|
settings_file = tmp_path / "settings.json"
|
|
settings_file.write_text(
|
|
json.dumps({"imap_host": "imap.example.test", "imap_user": "alice"}),
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setattr(core_db, "engine", engine)
|
|
monkeypatch.setattr(core_db, "SessionLocal", factory)
|
|
monkeypatch.setattr(core_db, "SETTINGS_FILE", str(settings_file))
|
|
|
|
read_barrier = threading.Barrier(2)
|
|
real_read_text = Path.read_text
|
|
|
|
def synchronized_read(path, *args, **kwargs):
|
|
value = real_read_text(path, *args, **kwargs)
|
|
if path == settings_file:
|
|
read_barrier.wait(5)
|
|
return value
|
|
|
|
monkeypatch.setattr(Path, "read_text", synchronized_read)
|
|
threads = [
|
|
threading.Thread(target=core_db._migrate_seed_email_account)
|
|
for _ in range(2)
|
|
]
|
|
try:
|
|
for thread in threads:
|
|
thread.start()
|
|
for thread in threads:
|
|
thread.join(5)
|
|
assert all(not thread.is_alive() for thread in threads)
|
|
|
|
with engine.connect() as conn:
|
|
rows = conn.execute(text("""
|
|
SELECT owner, is_default FROM email_accounts
|
|
ORDER BY id
|
|
""")).all()
|
|
assert rows == [(None, 1)]
|
|
assert "seed email account migration:" not in caplog.text
|
|
finally:
|
|
engine.dispose()
|
|
|
|
|
|
def test_multi_owner_row_locks_are_acquired_in_canonical_order():
|
|
from core.database import lock_email_account_owner_mutations
|
|
|
|
class FakeSession:
|
|
def __init__(self):
|
|
self.locked = []
|
|
|
|
def get_bind(self):
|
|
return SimpleNamespace(dialect=SimpleNamespace(name="postgresql"))
|
|
|
|
def get(self, _model, owner_key, **kwargs):
|
|
assert kwargs == {"with_for_update": True}
|
|
self.locked.append(owner_key)
|
|
return object()
|
|
|
|
db = FakeSession()
|
|
lock_email_account_owner_mutations(db, "zeta", "", "alpha", "zeta")
|
|
assert db.locked == ["", "alpha", "zeta"]
|
|
|
|
|
|
def test_postgresql_fresh_schema_emits_default_unique_index():
|
|
from core import database as core_db
|
|
|
|
statements = []
|
|
engine_holder = {}
|
|
|
|
def capture(statement, *_args, **_kwargs):
|
|
statements.append(
|
|
str(statement.compile(dialect=engine_holder["engine"].dialect))
|
|
)
|
|
|
|
mock_engine = create_mock_engine("postgresql://", capture)
|
|
engine_holder["engine"] = mock_engine
|
|
core_db.EmailAccount.__table__.create(mock_engine)
|
|
|
|
assert any(
|
|
core_db._EMAIL_ACCOUNT_DEFAULT_INDEX in statement
|
|
and "COALESCE(owner, '')" in statement
|
|
and "WHERE is_default IS TRUE" in statement
|
|
for statement in statements
|
|
)
|
|
|
|
|
|
def test_rename_serializes_old_and_new_owner_and_stale_set_default_fails_closed(
|
|
account_db, monkeypatch, tmp_path
|
|
):
|
|
from core import database as core_db
|
|
from routes import auth_routes
|
|
|
|
_seed_account(account_db, "alice-a", "alice", is_default=True)
|
|
_seed_account(account_db, "alice-b", "alice")
|
|
_seed_account(account_db, "bob-a", "bob", is_default=True)
|
|
|
|
prefs_module = types.ModuleType("routes.prefs_routes")
|
|
prefs_module._load = lambda: {}
|
|
prefs_module._save = lambda _data: None
|
|
monkeypatch.setitem(sys.modules, "routes.prefs_routes", prefs_module)
|
|
monkeypatch.setattr(
|
|
auth_routes, "DEEP_RESEARCH_DIR", str(tmp_path / "deep_research")
|
|
)
|
|
monkeypatch.setattr(auth_routes, "MEMORY_FILE", str(tmp_path / "memory.json"))
|
|
monkeypatch.setattr(auth_routes, "SKILLS_DIR", str(tmp_path / "skills"))
|
|
|
|
auth_manager = mock.MagicMock()
|
|
auth_manager.get_username_for_token.return_value = "admin"
|
|
auth_manager.is_admin.return_value = True
|
|
auth_manager.users = {"admin": {}, "alice": {}}
|
|
auth_manager.rename_user.return_value = True
|
|
rename_user = _named_endpoint(
|
|
auth_routes.setup_auth_routes(auth_manager), "rename_user"
|
|
)
|
|
set_default = _endpoint("POST", "/api/email/accounts/{account_id}/set-default")
|
|
|
|
rename_acquired = threading.Event()
|
|
release_rename = threading.Event()
|
|
set_attempted = threading.Event()
|
|
set_acquired = threading.Event()
|
|
real_lock = core_db.lock_email_account_owner_mutations
|
|
|
|
def controlled_lock(db, *owners):
|
|
thread_name = threading.current_thread().name
|
|
if thread_name == "rename-owner":
|
|
real_lock(db, *owners)
|
|
rename_acquired.set()
|
|
assert release_rename.wait(5)
|
|
return
|
|
if thread_name == "stale-set-default":
|
|
set_attempted.set()
|
|
real_lock(db, *owners)
|
|
set_acquired.set()
|
|
return
|
|
real_lock(db, *owners)
|
|
|
|
monkeypatch.setattr(core_db, "lock_email_account_owner_mutations", controlled_lock)
|
|
request = SimpleNamespace(
|
|
cookies={"odysseus_session": "admin-token"},
|
|
app=SimpleNamespace(
|
|
state=SimpleNamespace(
|
|
invalidate_token_cache=lambda: None,
|
|
session_manager=None,
|
|
research_handler=None,
|
|
upload_handler=None,
|
|
personal_docs_manager=None,
|
|
)
|
|
),
|
|
)
|
|
results = {}
|
|
|
|
def rename_owner():
|
|
results["rename"] = asyncio.run(
|
|
rename_user("alice", SimpleNamespace(username="bob"), request)
|
|
)
|
|
|
|
def select_stale_default():
|
|
try:
|
|
results["set"] = asyncio.run(
|
|
set_default("alice-b", owner="alice")
|
|
)
|
|
except Exception as exc: # asserted below with its HTTP status
|
|
results["set_error"] = exc
|
|
|
|
rename_thread = threading.Thread(target=rename_owner, name="rename-owner")
|
|
set_thread = threading.Thread(
|
|
target=select_stale_default, name="stale-set-default"
|
|
)
|
|
rename_thread.start()
|
|
assert rename_acquired.wait(5)
|
|
set_thread.start()
|
|
assert set_attempted.wait(5)
|
|
assert not set_acquired.wait(0.1), "set-default bypassed the rename lock"
|
|
|
|
release_rename.set()
|
|
rename_thread.join(5)
|
|
set_thread.join(5)
|
|
|
|
assert not rename_thread.is_alive()
|
|
assert not set_thread.is_alive()
|
|
assert results["rename"]["ok"] is True
|
|
assert isinstance(results["set_error"], HTTPException)
|
|
assert results["set_error"].status_code == 404
|
|
assert _rows(account_db) == [
|
|
("alice-a", "bob", False),
|
|
("alice-b", "bob", False),
|
|
("bob-a", "bob", True),
|
|
]
|
|
|
|
|
|
def test_demo_teardown_promotes_replacement_in_same_transaction(
|
|
account_db, monkeypatch
|
|
):
|
|
from core.database import EmailAccount
|
|
from scripts.demo_email import demo_account
|
|
|
|
db = account_db()
|
|
try:
|
|
db.add_all([
|
|
EmailAccount(
|
|
id="real",
|
|
owner="",
|
|
name="Real",
|
|
is_default=False,
|
|
enabled=True,
|
|
),
|
|
EmailAccount(
|
|
id="demo",
|
|
owner="",
|
|
name=demo_account.NAME,
|
|
imap_user=demo_account.IMAP_USER,
|
|
is_default=True,
|
|
enabled=True,
|
|
),
|
|
])
|
|
db.commit()
|
|
finally:
|
|
db.close()
|
|
|
|
monkeypatch.setattr(demo_account, "SessionLocal", account_db)
|
|
monkeypatch.setattr(demo_account, "engine", account_db.kw["bind"])
|
|
|
|
assert demo_account.teardown() == 0
|
|
assert _rows(account_db) == [("real", "", True)]
|