test_users_auth_cleanup.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  1. """Integration tests for OIDC/MFA cleanup on user deletion.
  2. These tests verify the fix for issue #1285: deleting a user via DELETE
  3. /api/v1/users/{id} must also remove their UserOIDCLink, UserTOTP, and
  4. UserOTPCode rows. On PostgreSQL the FK CASCADE handles this, but SQLite
  5. ships with FK enforcement off — without explicit DELETEs in the endpoint,
  6. orphan rows would block SSO re-login and leak MFA secrets.
  7. """
  8. from datetime import datetime, timedelta, timezone
  9. import pytest
  10. from httpx import AsyncClient
  11. from sqlalchemy import select
  12. from sqlalchemy.ext.asyncio import AsyncSession
  13. class TestDeleteUserCleansAuthRows:
  14. """Verify delete_user removes OIDC link + TOTP + OTP rows owned by the user."""
  15. @pytest.fixture
  16. async def auth_token(self, async_client: AsyncClient):
  17. """Setup auth and return admin token."""
  18. await async_client.post(
  19. "/api/v1/auth/setup",
  20. json={
  21. "auth_enabled": True,
  22. "admin_username": "cleanupadmin",
  23. "admin_password": "AdminPass1!",
  24. },
  25. )
  26. login_response = await async_client.post(
  27. "/api/v1/auth/login",
  28. json={"username": "cleanupadmin", "password": "AdminPass1!"},
  29. )
  30. return login_response.json()["access_token"]
  31. async def _create_user(self, async_client: AsyncClient, auth_token: str, username: str) -> int:
  32. """Helper: create a non-admin user via the API and return their id."""
  33. create_resp = await async_client.post(
  34. "/api/v1/users/",
  35. headers={"Authorization": f"Bearer {auth_token}"},
  36. json={
  37. "username": username,
  38. "password": "Password123!",
  39. "role": "user",
  40. },
  41. )
  42. assert create_resp.status_code in (200, 201), create_resp.text
  43. return create_resp.json()["id"]
  44. @pytest.mark.asyncio
  45. @pytest.mark.integration
  46. async def test_delete_user_removes_oidc_links(
  47. self,
  48. async_client: AsyncClient,
  49. db_session: AsyncSession,
  50. auth_token: str,
  51. ):
  52. """Deleting a user must also delete their UserOIDCLink rows."""
  53. from backend.app.models.oidc_provider import OIDCProvider, UserOIDCLink
  54. user_id = await self._create_user(async_client, auth_token, "oidcclean")
  55. # Use the client_secret property setter (mfa_encrypt) instead of poking
  56. # _client_secret_enc directly — keeps the fixture in sync with the real
  57. # encryption flow even though nothing decrypts it in this test
  58. # (#1295 review nit).
  59. provider = OIDCProvider(
  60. name="CleanupProv",
  61. issuer_url="https://cleanup.example.com",
  62. client_id="cleanup_client",
  63. scopes="openid email profile",
  64. is_enabled=True,
  65. )
  66. provider.client_secret = "cleanup_secret"
  67. db_session.add(provider)
  68. await db_session.flush()
  69. db_session.add(
  70. UserOIDCLink(
  71. user_id=user_id,
  72. provider_id=provider.id,
  73. provider_user_id="sub-cleanup-123",
  74. provider_email="cleanup@example.com",
  75. )
  76. )
  77. await db_session.commit()
  78. # Sanity check: link exists before delete
  79. pre = await db_session.execute(select(UserOIDCLink).where(UserOIDCLink.user_id == user_id))
  80. assert pre.scalar_one_or_none() is not None
  81. # Delete via API
  82. resp = await async_client.delete(
  83. f"/api/v1/users/{user_id}",
  84. headers={"Authorization": f"Bearer {auth_token}"},
  85. )
  86. assert resp.status_code == 204
  87. # Link must be gone (the bug from #1285 is when it persists on SQLite)
  88. await db_session.commit()
  89. post = await db_session.execute(select(UserOIDCLink).where(UserOIDCLink.user_id == user_id))
  90. assert post.scalar_one_or_none() is None, "UserOIDCLink orphan left behind — #1285 regression"
  91. @pytest.mark.asyncio
  92. @pytest.mark.integration
  93. async def test_delete_user_removes_user_totp(
  94. self,
  95. async_client: AsyncClient,
  96. db_session: AsyncSession,
  97. auth_token: str,
  98. ):
  99. """Deleting a user must also delete their UserTOTP row (MFA secret)."""
  100. from backend.app.models.user_totp import UserTOTP
  101. user_id = await self._create_user(async_client, auth_token, "totpclean")
  102. totp = UserTOTP(user_id=user_id, is_enabled=True)
  103. totp.secret = "JBSWY3DPEHPK3PXP" # encrypts via property setter
  104. db_session.add(totp)
  105. await db_session.commit()
  106. pre = await db_session.execute(select(UserTOTP).where(UserTOTP.user_id == user_id))
  107. assert pre.scalar_one_or_none() is not None
  108. resp = await async_client.delete(
  109. f"/api/v1/users/{user_id}",
  110. headers={"Authorization": f"Bearer {auth_token}"},
  111. )
  112. assert resp.status_code == 204
  113. await db_session.commit()
  114. post = await db_session.execute(select(UserTOTP).where(UserTOTP.user_id == user_id))
  115. assert post.scalar_one_or_none() is None, "UserTOTP orphan — MFA secret leaked after user delete"
  116. @pytest.mark.asyncio
  117. @pytest.mark.integration
  118. async def test_delete_user_removes_long_lived_tokens(
  119. self,
  120. async_client: AsyncClient,
  121. db_session: AsyncSession,
  122. auth_token: str,
  123. ):
  124. """Deleting a user must also delete their LongLivedToken rows.
  125. Camera-stream tokens whose `secret_hash` is still valid would
  126. otherwise be matchable by `verify()` via `lookup_prefix` even
  127. after the user is gone (#1295 review feedback).
  128. """
  129. from backend.app.models.long_lived_token import LongLivedToken
  130. user_id = await self._create_user(async_client, auth_token, "lltclean")
  131. db_session.add(
  132. LongLivedToken(
  133. user_id=user_id,
  134. name="HA card",
  135. lookup_prefix="abcd1234",
  136. secret_hash="$2b$12$dummybcrypthashabcdefghij1234567890",
  137. scope="camera_stream",
  138. expires_at=datetime.now(timezone.utc) + timedelta(days=30),
  139. )
  140. )
  141. await db_session.commit()
  142. pre = await db_session.execute(select(LongLivedToken).where(LongLivedToken.user_id == user_id))
  143. assert pre.scalar_one_or_none() is not None
  144. resp = await async_client.delete(
  145. f"/api/v1/users/{user_id}",
  146. headers={"Authorization": f"Bearer {auth_token}"},
  147. )
  148. assert resp.status_code == 204
  149. await db_session.commit()
  150. post = await db_session.execute(select(LongLivedToken).where(LongLivedToken.user_id == user_id))
  151. assert post.scalar_one_or_none() is None, (
  152. "LongLivedToken orphan — camera-stream secret still in DB after user delete"
  153. )
  154. @pytest.mark.asyncio
  155. @pytest.mark.integration
  156. async def test_delete_user_removes_user_otp_codes(
  157. self,
  158. async_client: AsyncClient,
  159. db_session: AsyncSession,
  160. auth_token: str,
  161. ):
  162. """Deleting a user must also delete their UserOTPCode rows."""
  163. from backend.app.models.user_otp_code import UserOTPCode
  164. user_id = await self._create_user(async_client, auth_token, "otpclean")
  165. # Two pending OTP codes so we verify the WHERE clause hits all rows
  166. for _ in range(2):
  167. db_session.add(
  168. UserOTPCode(
  169. user_id=user_id,
  170. code_hash="$pbkdf2-sha256$dummy",
  171. expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),
  172. )
  173. )
  174. await db_session.commit()
  175. pre = await db_session.execute(select(UserOTPCode).where(UserOTPCode.user_id == user_id))
  176. assert len(pre.scalars().all()) == 2
  177. resp = await async_client.delete(
  178. f"/api/v1/users/{user_id}",
  179. headers={"Authorization": f"Bearer {auth_token}"},
  180. )
  181. assert resp.status_code == 204
  182. await db_session.commit()
  183. post = await db_session.execute(select(UserOTPCode).where(UserOTPCode.user_id == user_id))
  184. assert post.scalars().all() == [], "UserOTPCode orphans left behind"
  185. @pytest.mark.asyncio
  186. @pytest.mark.integration
  187. async def test_delete_user_with_all_auth_rows(
  188. self,
  189. async_client: AsyncClient,
  190. db_session: AsyncSession,
  191. auth_token: str,
  192. ):
  193. """Combined: one user with OIDC link + TOTP + OTP + long-lived token — all cleaned up atomically."""
  194. from backend.app.models.long_lived_token import LongLivedToken
  195. from backend.app.models.oidc_provider import OIDCProvider, UserOIDCLink
  196. from backend.app.models.user_otp_code import UserOTPCode
  197. from backend.app.models.user_totp import UserTOTP
  198. user_id = await self._create_user(async_client, auth_token, "fullauth")
  199. provider = OIDCProvider(
  200. name="FullAuthProv",
  201. issuer_url="https://fullauth.example.com",
  202. client_id="fullauth_client",
  203. scopes="openid email profile",
  204. is_enabled=True,
  205. )
  206. provider.client_secret = "fullauth_secret"
  207. db_session.add(provider)
  208. await db_session.flush()
  209. db_session.add(
  210. UserOIDCLink(
  211. user_id=user_id,
  212. provider_id=provider.id,
  213. provider_user_id="sub-fullauth",
  214. provider_email="full@example.com",
  215. )
  216. )
  217. totp = UserTOTP(user_id=user_id, is_enabled=True)
  218. totp.secret = "JBSWY3DPEHPK3PXP"
  219. db_session.add(totp)
  220. db_session.add(
  221. UserOTPCode(
  222. user_id=user_id,
  223. code_hash="$pbkdf2-sha256$dummy",
  224. expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),
  225. )
  226. )
  227. db_session.add(
  228. LongLivedToken(
  229. user_id=user_id,
  230. name="combined-test",
  231. lookup_prefix="zz999999",
  232. secret_hash="$2b$12$dummybcrypthashabcdefghij1234567890",
  233. scope="camera_stream",
  234. expires_at=datetime.now(timezone.utc) + timedelta(days=30),
  235. )
  236. )
  237. await db_session.commit()
  238. resp = await async_client.delete(
  239. f"/api/v1/users/{user_id}",
  240. headers={"Authorization": f"Bearer {auth_token}"},
  241. )
  242. assert resp.status_code == 204
  243. await db_session.commit()
  244. link_post = await db_session.execute(select(UserOIDCLink).where(UserOIDCLink.user_id == user_id))
  245. totp_post = await db_session.execute(select(UserTOTP).where(UserTOTP.user_id == user_id))
  246. otp_post = await db_session.execute(select(UserOTPCode).where(UserOTPCode.user_id == user_id))
  247. llt_post = await db_session.execute(select(LongLivedToken).where(LongLivedToken.user_id == user_id))
  248. assert link_post.scalar_one_or_none() is None
  249. assert totp_post.scalar_one_or_none() is None
  250. assert otp_post.scalars().all() == []
  251. assert llt_post.scalar_one_or_none() is None