test_user_delete_id_reuse.py 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. """A new user must not inherit what belonged to a deleted user.
  2. SQLite runs with foreign keys off and gives the highest freed id to the next
  3. row, so deleting the newest user and creating another one handed the new user
  4. everything still attached to that id: wallet balance, transactions, cost centre
  5. memberships, a private cost centre, private library folders.
  6. """
  7. import pytest
  8. from httpx import AsyncClient
  9. from sqlalchemy import select, text
  10. from backend.app.core import sqlite_fk
  11. from backend.app.core.database import load_all_models
  12. from backend.app.models.finance import CostCenter, CostCenterMember, UserWallet, WalletTransaction
  13. from backend.app.models.library import LibraryFolder
  14. from backend.app.models.settings import Settings
  15. from backend.app.models.user import User
  16. async def _user(db, name: str, user_id: int | None = None) -> int:
  17. user = User(id=user_id, username=name, password_hash="x", role="user")
  18. db.add(user)
  19. await db.commit()
  20. await db.refresh(user)
  21. return user.id
  22. async def _belongings(db, user_id: int) -> tuple[int, int]:
  23. """A wallet, a deposit, a can_print membership, a private cost centre and a private folder."""
  24. shared = CostCenter(name="Lab", is_private=False)
  25. private = CostCenter(name="Private", is_private=True, owner_user_id=user_id)
  26. db.add_all([shared, private])
  27. await db.commit()
  28. folder = LibraryFolder(name="Secret", created_by_id=user_id)
  29. db.add_all(
  30. [
  31. UserWallet(user_id=user_id, balance=42.0),
  32. CostCenterMember(cost_center_id=shared.id, user_id=user_id, can_print=True),
  33. WalletTransaction(user_id=user_id, amount=42.0, transaction_type="deposit"),
  34. folder,
  35. ]
  36. )
  37. await db.commit()
  38. return private.id, folder.id
  39. async def _inherited(db, user_id: int) -> dict[str, list]:
  40. checks = {
  41. "wallet": select(UserWallet.balance).where(UserWallet.user_id == user_id),
  42. "transactions": select(WalletTransaction.amount).where(WalletTransaction.user_id == user_id),
  43. "memberships": select(CostCenterMember.can_print).where(CostCenterMember.user_id == user_id),
  44. "private cost centres": select(CostCenter.name).where(CostCenter.owner_user_id == user_id),
  45. "folders": select(LibraryFolder.name).where(LibraryFolder.created_by_id == user_id),
  46. }
  47. return {name: list((await db.execute(query)).scalars().all()) for name, query in checks.items()}
  48. NOTHING = {"wallet": [], "transactions": [], "memberships": [], "private cost centres": [], "folders": []}
  49. @pytest.mark.asyncio
  50. @pytest.mark.integration
  51. async def test_a_user_with_the_deleted_users_id_inherits_nothing(async_client: AsyncClient, db_session):
  52. alice = await _user(db_session, "alice")
  53. private_id, folder_id = await _belongings(db_session, alice)
  54. response = await async_client.delete(f"/api/v1/users/{alice}")
  55. assert response.status_code == 204, response.text
  56. # AUTOINCREMENT (core/database) now stops SQLite reusing ids; the new user is
  57. # put on the freed id by hand, as on databases from before, to keep the
  58. # delete's own cleanup covered.
  59. bob = await _user(db_session, "bob", alice)
  60. assert bob == alice
  61. assert await _inherited(db_session, bob) == NOTHING
  62. # What was alice's private cost centre and folder now has no owner, which
  63. # leaves them to admins (not to everyone).
  64. owner = (await db_session.execute(select(CostCenter.owner_user_id).where(CostCenter.id == private_id))).scalar()
  65. folder = (await db_session.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))).scalar_one()
  66. assert owner is None
  67. assert folder.created_by_id is None and not folder.shared
  68. @pytest.mark.asyncio
  69. @pytest.mark.integration
  70. async def test_the_startup_repair_clears_what_an_earlier_delete_left(db_session):
  71. """Installs that already deleted a user before this fix are repaired at start."""
  72. alice = await _user(db_session, "alice")
  73. await _belongings(db_session, alice)
  74. await db_session.execute(text("DELETE FROM users WHERE id = :id"), {"id": alice}) # the old way
  75. await db_session.commit()
  76. await sqlite_fk.sweep_orphans(await db_session.connection())
  77. await db_session.commit()
  78. assert await _inherited(db_session, alice) == NOTHING
  79. @pytest.mark.asyncio
  80. @pytest.mark.integration
  81. async def test_the_ledger_keeps_a_deleted_users_transactions(async_client: AsyncClient, db_session):
  82. """Their charges to a shared cost centre stay spent, and the ledger stays whole."""
  83. from backend.app.core.database import repair_wallet_ledger_internal
  84. alice = await _user(db_session, "alice")
  85. lab = CostCenter(name="Lab", is_private=False, total_budget=100.0)
  86. db_session.add(lab)
  87. await db_session.commit()
  88. db_session.add_all(
  89. [
  90. WalletTransaction(user_id=alice, amount=42.0, balance_after=42.0, transaction_type="deposit"),
  91. WalletTransaction(
  92. user_id=alice, cost_center_id=lab.id, amount=-30.0, balance_after=-30.0, transaction_type="print_charge"
  93. ),
  94. ]
  95. )
  96. await db_session.commit()
  97. response = await async_client.delete(f"/api/v1/users/{alice}")
  98. assert response.status_code == 204, response.text
  99. await repair_wallet_ledger_internal(db_session)
  100. await db_session.commit()
  101. rows = (
  102. await db_session.execute(
  103. select(WalletTransaction.user_id, WalletTransaction.cost_center_id, WalletTransaction.balance_after)
  104. .order_by(WalletTransaction.id)
  105. .execution_options(populate_existing=True)
  106. )
  107. ).all()
  108. assert [tuple(row) for row in rows] == [(None, None, 42.0), (None, lab.id, -30.0)]
  109. @pytest.mark.asyncio
  110. @pytest.mark.integration
  111. async def test_the_startup_repair_keeps_the_transactions_of_an_earlier_delete(db_session):
  112. alice = await _user(db_session, "alice")
  113. db_session.add(WalletTransaction(user_id=alice, amount=-5.0, transaction_type="print_charge"))
  114. await db_session.commit()
  115. await db_session.execute(text("DELETE FROM users WHERE id = :id"), {"id": alice})
  116. await db_session.commit()
  117. await sqlite_fk.sweep_orphans(await db_session.connection())
  118. await db_session.commit()
  119. amounts = (await db_session.execute(select(WalletTransaction.amount, WalletTransaction.user_id))).all()
  120. assert [tuple(row) for row in amounts] == [(-5.0, None)]
  121. @pytest.mark.asyncio
  122. @pytest.mark.integration
  123. async def test_the_next_user_with_the_id_is_not_asked_for_an_email_code(async_client: AsyncClient, db_session):
  124. """Email 2FA is a settings key named after the user id, which no foreign key covers."""
  125. from backend.app.api.routes.mfa import _get_email_2fa_enabled, _set_email_2fa_enabled
  126. alice = await _user(db_session, "alice")
  127. await _set_email_2fa_enabled(db_session, alice, True)
  128. await db_session.commit()
  129. response = await async_client.delete(f"/api/v1/users/{alice}")
  130. assert response.status_code == 204, response.text
  131. bob = await _user(db_session, "bob", alice) # on the freed id, as before AUTOINCREMENT
  132. assert bob == alice
  133. assert await _get_email_2fa_enabled(db_session, bob) is False
  134. @pytest.mark.sqlite_only # the startup repair of SQLite leftovers
  135. @pytest.mark.asyncio
  136. @pytest.mark.integration
  137. async def test_the_startup_repair_drops_email_2fa_of_users_already_deleted(db_session):
  138. from backend.app.api.routes.mfa import _get_email_2fa_enabled, _set_email_2fa_enabled
  139. from backend.app.core.database import _repair_sqlite_orphans
  140. alice, keep = await _user(db_session, "alice"), await _user(db_session, "keep")
  141. await _set_email_2fa_enabled(db_session, alice, True)
  142. await _set_email_2fa_enabled(db_session, keep, True)
  143. await db_session.execute(text("DELETE FROM users WHERE id = :id"), {"id": alice}) # the old way
  144. await db_session.commit()
  145. await _repair_sqlite_orphans(await db_session.connection())
  146. await db_session.commit()
  147. keys = (await db_session.execute(select(Settings.key).where(Settings.key.like("user_%_email_2fa_enabled")))).all()
  148. assert [key for (key,) in keys] == [f"user_{keep}_email_2fa_enabled"]
  149. assert await _get_email_2fa_enabled(db_session, keep) is True
  150. def test_every_reference_to_users_is_applied():
  151. """A new table that points at users must not bring the inheritance back."""
  152. to_users = {
  153. (table.name, fk.parent.name)
  154. for table in load_all_models().sorted_tables
  155. for fk in table.foreign_keys
  156. if fk.column.table.name == "users" and fk.ondelete in ("CASCADE", "SET NULL")
  157. }
  158. assert to_users
  159. assert to_users - sqlite_fk.APPLIED == set()