| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198 |
- """A new user must not inherit what belonged to a deleted user.
- SQLite runs with foreign keys off and gives the highest freed id to the next
- row, so deleting the newest user and creating another one handed the new user
- everything still attached to that id: wallet balance, transactions, cost centre
- memberships, a private cost centre, private library folders.
- """
- import pytest
- from httpx import AsyncClient
- from sqlalchemy import select, text
- from backend.app.core import sqlite_fk
- from backend.app.core.database import load_all_models
- from backend.app.models.finance import CostCenter, CostCenterMember, UserWallet, WalletTransaction
- from backend.app.models.library import LibraryFolder
- from backend.app.models.settings import Settings
- from backend.app.models.user import User
- async def _user(db, name: str, user_id: int | None = None) -> int:
- user = User(id=user_id, username=name, password_hash="x", role="user")
- db.add(user)
- await db.commit()
- await db.refresh(user)
- return user.id
- async def _belongings(db, user_id: int) -> tuple[int, int]:
- """A wallet, a deposit, a can_print membership, a private cost centre and a private folder."""
- shared = CostCenter(name="Lab", is_private=False)
- private = CostCenter(name="Private", is_private=True, owner_user_id=user_id)
- db.add_all([shared, private])
- await db.commit()
- folder = LibraryFolder(name="Secret", created_by_id=user_id)
- db.add_all(
- [
- UserWallet(user_id=user_id, balance=42.0),
- CostCenterMember(cost_center_id=shared.id, user_id=user_id, can_print=True),
- WalletTransaction(user_id=user_id, amount=42.0, transaction_type="deposit"),
- folder,
- ]
- )
- await db.commit()
- return private.id, folder.id
- async def _inherited(db, user_id: int) -> dict[str, list]:
- checks = {
- "wallet": select(UserWallet.balance).where(UserWallet.user_id == user_id),
- "transactions": select(WalletTransaction.amount).where(WalletTransaction.user_id == user_id),
- "memberships": select(CostCenterMember.can_print).where(CostCenterMember.user_id == user_id),
- "private cost centres": select(CostCenter.name).where(CostCenter.owner_user_id == user_id),
- "folders": select(LibraryFolder.name).where(LibraryFolder.created_by_id == user_id),
- }
- return {name: list((await db.execute(query)).scalars().all()) for name, query in checks.items()}
- NOTHING = {"wallet": [], "transactions": [], "memberships": [], "private cost centres": [], "folders": []}
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_a_user_with_the_deleted_users_id_inherits_nothing(async_client: AsyncClient, db_session):
- alice = await _user(db_session, "alice")
- private_id, folder_id = await _belongings(db_session, alice)
- response = await async_client.delete(f"/api/v1/users/{alice}")
- assert response.status_code == 204, response.text
- # AUTOINCREMENT (core/database) now stops SQLite reusing ids; the new user is
- # put on the freed id by hand, as on databases from before, to keep the
- # delete's own cleanup covered.
- bob = await _user(db_session, "bob", alice)
- assert bob == alice
- assert await _inherited(db_session, bob) == NOTHING
- # What was alice's private cost centre and folder now has no owner, which
- # leaves them to admins (not to everyone).
- owner = (await db_session.execute(select(CostCenter.owner_user_id).where(CostCenter.id == private_id))).scalar()
- folder = (await db_session.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))).scalar_one()
- assert owner is None
- assert folder.created_by_id is None and not folder.shared
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_the_startup_repair_clears_what_an_earlier_delete_left(db_session):
- """Installs that already deleted a user before this fix are repaired at start."""
- alice = await _user(db_session, "alice")
- await _belongings(db_session, alice)
- await db_session.execute(text("DELETE FROM users WHERE id = :id"), {"id": alice}) # the old way
- await db_session.commit()
- await sqlite_fk.sweep_orphans(await db_session.connection())
- await db_session.commit()
- assert await _inherited(db_session, alice) == NOTHING
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_the_ledger_keeps_a_deleted_users_transactions(async_client: AsyncClient, db_session):
- """Their charges to a shared cost centre stay spent, and the ledger stays whole."""
- from backend.app.core.database import repair_wallet_ledger_internal
- alice = await _user(db_session, "alice")
- lab = CostCenter(name="Lab", is_private=False, total_budget=100.0)
- db_session.add(lab)
- await db_session.commit()
- db_session.add_all(
- [
- WalletTransaction(user_id=alice, amount=42.0, balance_after=42.0, transaction_type="deposit"),
- WalletTransaction(
- user_id=alice, cost_center_id=lab.id, amount=-30.0, balance_after=-30.0, transaction_type="print_charge"
- ),
- ]
- )
- await db_session.commit()
- response = await async_client.delete(f"/api/v1/users/{alice}")
- assert response.status_code == 204, response.text
- await repair_wallet_ledger_internal(db_session)
- await db_session.commit()
- rows = (
- await db_session.execute(
- select(WalletTransaction.user_id, WalletTransaction.cost_center_id, WalletTransaction.balance_after)
- .order_by(WalletTransaction.id)
- .execution_options(populate_existing=True)
- )
- ).all()
- assert [tuple(row) for row in rows] == [(None, None, 42.0), (None, lab.id, -30.0)]
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_the_startup_repair_keeps_the_transactions_of_an_earlier_delete(db_session):
- alice = await _user(db_session, "alice")
- db_session.add(WalletTransaction(user_id=alice, amount=-5.0, transaction_type="print_charge"))
- await db_session.commit()
- await db_session.execute(text("DELETE FROM users WHERE id = :id"), {"id": alice})
- await db_session.commit()
- await sqlite_fk.sweep_orphans(await db_session.connection())
- await db_session.commit()
- amounts = (await db_session.execute(select(WalletTransaction.amount, WalletTransaction.user_id))).all()
- assert [tuple(row) for row in amounts] == [(-5.0, None)]
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_the_next_user_with_the_id_is_not_asked_for_an_email_code(async_client: AsyncClient, db_session):
- """Email 2FA is a settings key named after the user id, which no foreign key covers."""
- from backend.app.api.routes.mfa import _get_email_2fa_enabled, _set_email_2fa_enabled
- alice = await _user(db_session, "alice")
- await _set_email_2fa_enabled(db_session, alice, True)
- await db_session.commit()
- response = await async_client.delete(f"/api/v1/users/{alice}")
- assert response.status_code == 204, response.text
- bob = await _user(db_session, "bob", alice) # on the freed id, as before AUTOINCREMENT
- assert bob == alice
- assert await _get_email_2fa_enabled(db_session, bob) is False
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_the_startup_repair_drops_email_2fa_of_users_already_deleted(db_session):
- from backend.app.api.routes.mfa import _get_email_2fa_enabled, _set_email_2fa_enabled
- from backend.app.core.database import _repair_sqlite_orphans
- alice, keep = await _user(db_session, "alice"), await _user(db_session, "keep")
- await _set_email_2fa_enabled(db_session, alice, True)
- await _set_email_2fa_enabled(db_session, keep, True)
- await db_session.execute(text("DELETE FROM users WHERE id = :id"), {"id": alice}) # the old way
- await db_session.commit()
- await _repair_sqlite_orphans(await db_session.connection())
- await db_session.commit()
- keys = (await db_session.execute(select(Settings.key).where(Settings.key.like("user_%_email_2fa_enabled")))).all()
- assert [key for (key,) in keys] == [f"user_{keep}_email_2fa_enabled"]
- assert await _get_email_2fa_enabled(db_session, keep) is True
- def test_every_reference_to_users_is_applied():
- """A new table that points at users must not bring the inheritance back."""
- to_users = {
- (table.name, fk.parent.name)
- for table in load_all_models().sorted_tables
- for fk in table.foreign_keys
- if fk.column.table.name == "users" and fk.ondelete in ("CASCADE", "SET NULL")
- }
- assert to_users
- assert to_users - sqlite_fk.APPLIED == set()
|