diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-30 11:22:24 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-30 11:22:24 +0200 |
| commit | 69554fac7eba6eef7eb8a1c0111c5b92e7f21256 (patch) | |
| tree | 87ae908d008159c4237b31dade3f385a629c3c7b /packages/meshbay-hub/tests/test_cleanup_foreign_keys.py | |
| parent | e2a487c3cd24589b9d9bd298b79d8efaa9914480 (diff) | |
| download | meshbay-69554fac7eba6eef7eb8a1c0111c5b92e7f21256.tar.gz | |
fix: a member can no longer lock a node, crash it with a link, or stop hub cleanup
- node: only a wrong code counts towards the join lock, now per account
(5) as well as node-wide (20), and it is consulted only when a code is
tried. Every member reconnecting gets the group key through join_request,
so a lock checked before recognition let one member refuse it to everyone.
- node: link previews read the body as a stream and stop at the cap,
counted on decoded bytes; a declared oversized image is not read; 15 s
total deadline; image decoding off the loop. `client.get` had buffered
the whole (decompressed) response before the caps looked at it.
- hub: the daily purge of never-verified accounts detaches their IP-log
rows (keeping the name) and clears every other reference first, and each
cleanup step runs on its own. On PostgreSQL the bare DELETE violated the
ip_logs foreign key and stopped every purge behind it for good.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/tests/test_cleanup_foreign_keys.py')
| -rw-r--r-- | packages/meshbay-hub/tests/test_cleanup_foreign_keys.py | 103 |
1 files changed, 103 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_cleanup_foreign_keys.py b/packages/meshbay-hub/tests/test_cleanup_foreign_keys.py new file mode 100644 index 0000000..b4994b2 --- /dev/null +++ b/packages/meshbay-hub/tests/test_cleanup_foreign_keys.py @@ -0,0 +1,103 @@ +""" +The daily cleanup, against a database that enforces foreign keys. + +PostgreSQL always does; the SQLite the rest of the suite runs on does not unless +asked. So `DELETE FROM users` for an account that still had an IP-log row passed +every test here and raised on the real hub — and because every step shared one +`try`, the purges behind it (mail counters, sign-in counters, invitation links) +stopped running for good. Both halves are held here, on an engine with +`PRAGMA foreign_keys=ON`. +""" + +from datetime import UTC, datetime, timedelta + +import pytest +from meshbay_hub.db.models import ( + Base, + EmailVerification, + Group, + GroupMember, + IPLog, + Notification, + User, +) +from meshbay_hub.tasks import cleanup +from sqlalchemy import event, select +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + + +@pytest.fixture +async def strict_db(tmp_path): + engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'hub.db'}") + + @event.listens_for(engine.sync_engine, "connect") + def _enforce(dbapi_conn, _record): + dbapi_conn.execute("PRAGMA foreign_keys=ON") + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield async_sessionmaker(engine, expire_on_commit=False) + await engine.dispose() + + +def _user(name, status, age_days): + return User(username=name, email="x", pw_hash=b"x", pw_salt=b"x", hub_id="h", + status=status, + created_at=datetime.now(UTC) - timedelta(days=age_days)) + + +async def test_a_stale_pending_account_is_purged_with_what_points_at_it(strict_db): + async with strict_db() as db: + owner = _user("groupowner", "active", 30) + stale = _user("neververified", "pending", 8) + db.add_all([owner, stale]) + await db.flush() + group = Group(name="g", admin_id=owner.id) + db.add(group) + await db.flush() + db.add_all([ + IPLog(user_id=stale.id, event="account_create", ip_address="192.0.2.1"), + GroupMember(group_id=group.id, user_id=stale.id), + Notification(user_id=stale.id, kind="group_invite", title="t"), + EmailVerification(email_hash="h", code="1", purpose="registration", + user_id=stale.id, + expires_at=datetime.now(UTC) - timedelta(days=6)), + ]) + await db.commit() + stale_id = stale.id + + async with strict_db() as db: + assert await cleanup.purge_stale_pending_users(db) == 1 + + async with strict_db() as db: + assert await db.get(User, stale_id) is None + log_row = (await db.execute(select(IPLog))).scalar_one() + # The legal record survives, still saying who it was about. + assert log_row.user_id is None and log_row.username == "neververified" + assert (await db.execute(select(GroupMember).where( + GroupMember.user_id == stale_id))).first() is None + + +async def test_a_recent_or_active_account_is_left_alone(strict_db): + async with strict_db() as db: + db.add_all([_user("stillpending", "pending", 2), + _user("activeuser", "active", 30)]) + await db.commit() + async with strict_db() as db: + assert await cleanup.purge_stale_pending_users(db) == 0 + + +async def test_one_failing_step_does_not_stop_the_others(strict_db, monkeypatch): + ran = [] + + async def broken(db): + raise RuntimeError("boom") + + async def later(db): + ran.append("later") + return 0 + + monkeypatch.setattr(cleanup, "_STEPS", (("broken", broken), ("later", later))) + done = await cleanup.run_cleanup(strict_db) + assert done == {"broken": None, "later": 0} + assert ran == ["later"] |