test_user_delete_id_reuse.py 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  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) -> int:
  17. user = User(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. bob = await _user(db_session, "bob")
  57. assert bob == alice # SQLite reuses the freed id; the point of this test
  58. assert await _inherited(db_session, bob) == NOTHING
  59. # What was alice's private cost centre and folder now has no owner, which
  60. # leaves them to admins (not to everyone).
  61. owner = (await db_session.execute(select(CostCenter.owner_user_id).where(CostCenter.id == private_id))).scalar()
  62. folder = (await db_session.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))).scalar_one()
  63. assert owner is None
  64. assert folder.created_by_id is None and not folder.shared
  65. @pytest.mark.asyncio
  66. @pytest.mark.integration
  67. async def test_the_startup_repair_clears_what_an_earlier_delete_left(db_session):
  68. """Installs that already deleted a user before this fix are repaired at start."""
  69. alice = await _user(db_session, "alice")
  70. await _belongings(db_session, alice)
  71. await db_session.execute(text("DELETE FROM users WHERE id = :id"), {"id": alice}) # the old way
  72. await db_session.commit()
  73. await sqlite_fk.sweep_orphans(await db_session.connection())
  74. await db_session.commit()
  75. assert await _inherited(db_session, alice) == NOTHING
  76. @pytest.mark.asyncio
  77. @pytest.mark.integration
  78. async def test_the_ledger_keeps_a_deleted_users_transactions(async_client: AsyncClient, db_session):
  79. """Their charges to a shared cost centre stay spent, and the ledger stays whole."""
  80. from backend.app.core.database import repair_wallet_ledger_internal
  81. alice = await _user(db_session, "alice")
  82. lab = CostCenter(name="Lab", is_private=False, total_budget=100.0)
  83. db_session.add(lab)
  84. await db_session.commit()
  85. db_session.add_all(
  86. [
  87. WalletTransaction(user_id=alice, amount=42.0, balance_after=42.0, transaction_type="deposit"),
  88. WalletTransaction(
  89. user_id=alice, cost_center_id=lab.id, amount=-30.0, balance_after=-30.0, transaction_type="print_charge"
  90. ),
  91. ]
  92. )
  93. await db_session.commit()
  94. response = await async_client.delete(f"/api/v1/users/{alice}")
  95. assert response.status_code == 204, response.text
  96. await repair_wallet_ledger_internal(db_session)
  97. await db_session.commit()
  98. rows = (
  99. await db_session.execute(
  100. select(WalletTransaction.user_id, WalletTransaction.cost_center_id, WalletTransaction.balance_after)
  101. .order_by(WalletTransaction.id)
  102. .execution_options(populate_existing=True)
  103. )
  104. ).all()
  105. assert [tuple(row) for row in rows] == [(None, None, 42.0), (None, lab.id, -30.0)]
  106. @pytest.mark.asyncio
  107. @pytest.mark.integration
  108. async def test_the_startup_repair_keeps_the_transactions_of_an_earlier_delete(db_session):
  109. alice = await _user(db_session, "alice")
  110. db_session.add(WalletTransaction(user_id=alice, amount=-5.0, transaction_type="print_charge"))
  111. await db_session.commit()
  112. await db_session.execute(text("DELETE FROM users WHERE id = :id"), {"id": alice})
  113. await db_session.commit()
  114. await sqlite_fk.sweep_orphans(await db_session.connection())
  115. await db_session.commit()
  116. amounts = (await db_session.execute(select(WalletTransaction.amount, WalletTransaction.user_id))).all()
  117. assert [tuple(row) for row in amounts] == [(-5.0, None)]
  118. @pytest.mark.asyncio
  119. @pytest.mark.integration
  120. async def test_the_next_user_with_the_id_is_not_asked_for_an_email_code(async_client: AsyncClient, db_session):
  121. """Email 2FA is a settings key named after the user id, which no foreign key covers."""
  122. from backend.app.api.routes.mfa import _get_email_2fa_enabled, _set_email_2fa_enabled
  123. alice = await _user(db_session, "alice")
  124. await _set_email_2fa_enabled(db_session, alice, True)
  125. await db_session.commit()
  126. response = await async_client.delete(f"/api/v1/users/{alice}")
  127. assert response.status_code == 204, response.text
  128. bob = await _user(db_session, "bob")
  129. assert bob == alice
  130. assert await _get_email_2fa_enabled(db_session, bob) is False
  131. @pytest.mark.asyncio
  132. @pytest.mark.integration
  133. async def test_the_startup_repair_drops_email_2fa_of_users_already_deleted(db_session):
  134. from backend.app.api.routes.mfa import _get_email_2fa_enabled, _set_email_2fa_enabled
  135. from backend.app.core.database import _repair_sqlite_orphans
  136. alice, keep = await _user(db_session, "alice"), await _user(db_session, "keep")
  137. await _set_email_2fa_enabled(db_session, alice, True)
  138. await _set_email_2fa_enabled(db_session, keep, True)
  139. await db_session.execute(text("DELETE FROM users WHERE id = :id"), {"id": alice}) # the old way
  140. await db_session.commit()
  141. await _repair_sqlite_orphans(await db_session.connection())
  142. await db_session.commit()
  143. keys = (await db_session.execute(select(Settings.key).where(Settings.key.like("user_%_email_2fa_enabled")))).all()
  144. assert [key for (key,) in keys] == [f"user_{keep}_email_2fa_enabled"]
  145. assert await _get_email_2fa_enabled(db_session, keep) is True
  146. def test_every_reference_to_users_is_applied():
  147. """A new table that points at users must not bring the inheritance back."""
  148. to_users = {
  149. (table.name, fk.parent.name)
  150. for table in load_all_models().sorted_tables
  151. for fk in table.foreign_keys
  152. if fk.column.table.name == "users" and fk.ondelete in ("CASCADE", "SET NULL")
  153. }
  154. assert to_users
  155. assert to_users - sqlite_fk.APPLIED == set()