test_orphan_auth_cleanup_migration.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  1. """Regression test for the orphan OIDC/MFA cleanup migration (#1285).
  2. On SQLite (PRAGMA foreign_keys=OFF by default), the ON DELETE CASCADE
  3. declared on user_oidc_links.user_id / user_totp.user_id /
  4. user_otp_codes.user_id is NOT enforced. Users deleted via the API before
  5. the fix (PR for #1285) left orphan rows pointing to non-existent users.
  6. The OIDC callback would then find the orphan UserOIDCLink, fail to load
  7. the deleted user, and redirect to ``account_inactive`` instead of running
  8. auto_create_users.
  9. run_migrations now sweeps orphans on every startup; this test verifies it
  10. on all three tables and proves idempotency + no-op behaviour on fresh DBs.
  11. """
  12. from __future__ import annotations
  13. from datetime import datetime, timedelta, timezone
  14. import pytest
  15. from sqlalchemy import text
  16. from sqlalchemy.ext.asyncio import create_async_engine
  17. from backend.app.core.database import run_migrations
  18. @pytest.fixture(autouse=True)
  19. def force_sqlite_dialect(monkeypatch):
  20. """Pin the SQLite branch in run_migrations regardless of env."""
  21. from backend.app.core import database as database_module, db_dialect
  22. monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
  23. monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
  24. monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
  25. def _register_all_models():
  26. """Import the models package so every Base.metadata table is registered.
  27. Previously this listed each submodule by hand and silently drifted from
  28. backend/app/models/__init__.py (#1295 review nit). Importing the package
  29. triggers __init__.py which covers most of the schema automatically.
  30. A handful of submodules are NOT re-exported from __init__.py yet but are
  31. required by run_migrations (they touch tables that don't appear in any
  32. re-exported model). Those are imported by submodule below so the test
  33. engine has the full schema available. Keep this list in sync with the
  34. set conftest.py imports for test_engine.
  35. """
  36. import backend.app.models # noqa: F401
  37. # Submodules whose tables are touched by run_migrations but which are
  38. # not re-exported from __init__.py.
  39. from backend.app.models import ( # noqa: F401
  40. external_link,
  41. print_queue,
  42. project_bom,
  43. slot_preset,
  44. spoolman_k_profile,
  45. spoolman_slot_assignment,
  46. virtual_printer,
  47. )
  48. @pytest.fixture
  49. async def engine_with_full_schema():
  50. """In-memory SQLite with the full schema via create_all (no manual SQL)."""
  51. from backend.app.core.database import Base
  52. _register_all_models()
  53. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  54. async with engine.begin() as conn:
  55. await conn.run_sync(Base.metadata.create_all)
  56. yield engine
  57. await engine.dispose()
  58. # -----------------------------------------------------------------------------
  59. # Per-table orphan cleanup
  60. # -----------------------------------------------------------------------------
  61. async def test_migration_deletes_orphan_user_oidc_links(engine_with_full_schema):
  62. """Orphan rows in user_oidc_links must be removed; rows pointing at a real
  63. user must stay."""
  64. async with engine_with_full_schema.begin() as conn:
  65. # One real user, one nonexistent referenced by an OIDC link
  66. await conn.execute(
  67. text(
  68. "INSERT INTO users (id, username, password_hash, is_active, created_at, updated_at, "
  69. "role, auth_source) VALUES (1, 'survivor', 'h', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, "
  70. "'user', 'local')"
  71. )
  72. )
  73. # Provider (any provider — the link only requires existence)
  74. await conn.execute(
  75. text(
  76. "INSERT INTO oidc_providers (id, name, issuer_url, client_id, client_secret, "
  77. "scopes, is_enabled, auto_create_users, auto_link_existing_accounts, email_claim, "
  78. "require_email_verified, created_at, updated_at) VALUES (1, 'p', 'https://x', 'c', "
  79. "'s', 'openid', 1, 1, 0, 'email', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
  80. )
  81. )
  82. # Valid link
  83. await conn.execute(
  84. text(
  85. "INSERT INTO user_oidc_links (id, user_id, provider_id, provider_user_id, created_at) "
  86. "VALUES (10, 1, 1, 'sub-real', CURRENT_TIMESTAMP)"
  87. )
  88. )
  89. # Orphan link — user_id=999 does not exist
  90. await conn.execute(
  91. text(
  92. "INSERT INTO user_oidc_links (id, user_id, provider_id, provider_user_id, created_at) "
  93. "VALUES (11, 999, 1, 'sub-orphan', CURRENT_TIMESTAMP)"
  94. )
  95. )
  96. async with engine_with_full_schema.begin() as conn:
  97. await run_migrations(conn)
  98. async with engine_with_full_schema.begin() as conn:
  99. ids = [row[0] for row in (await conn.execute(text("SELECT id FROM user_oidc_links ORDER BY id"))).all()]
  100. assert ids == [10], f"Expected only the valid link to survive, got {ids}"
  101. async def test_migration_deletes_orphan_user_totp(engine_with_full_schema):
  102. """Orphan rows in user_totp must be removed; rows for real users must stay."""
  103. async with engine_with_full_schema.begin() as conn:
  104. await conn.execute(
  105. text(
  106. "INSERT INTO users (id, username, password_hash, is_active, created_at, updated_at, "
  107. "role, auth_source) VALUES (1, 'survivor', 'h', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, "
  108. "'user', 'local')"
  109. )
  110. )
  111. # Valid TOTP
  112. await conn.execute(
  113. text(
  114. "INSERT INTO user_totp (id, user_id, secret, is_enabled, created_at, updated_at) "
  115. "VALUES (10, 1, 'enc', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
  116. )
  117. )
  118. # Orphan TOTP — user_id=999 does not exist (would never happen with FK on,
  119. # but SQLite tolerates it because PRAGMA foreign_keys=OFF)
  120. await conn.execute(
  121. text(
  122. "INSERT INTO user_totp (id, user_id, secret, is_enabled, created_at, updated_at) "
  123. "VALUES (11, 999, 'orphan_enc', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
  124. )
  125. )
  126. async with engine_with_full_schema.begin() as conn:
  127. await run_migrations(conn)
  128. async with engine_with_full_schema.begin() as conn:
  129. ids = [row[0] for row in (await conn.execute(text("SELECT id FROM user_totp ORDER BY id"))).all()]
  130. assert ids == [10], f"Expected only the valid TOTP row to survive, got {ids}"
  131. async def test_migration_deletes_orphan_user_otp_codes(engine_with_full_schema):
  132. """Orphan rows in user_otp_codes must be removed; rows for real users must stay."""
  133. exp = (datetime.now(timezone.utc) + timedelta(minutes=10)).isoformat()
  134. async with engine_with_full_schema.begin() as conn:
  135. await conn.execute(
  136. text(
  137. "INSERT INTO users (id, username, password_hash, is_active, created_at, updated_at, "
  138. "role, auth_source) VALUES (1, 'survivor', 'h', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, "
  139. "'user', 'local')"
  140. )
  141. )
  142. # Valid OTP code
  143. await conn.execute(
  144. text(
  145. "INSERT INTO user_otp_codes (id, user_id, code_hash, attempts, used, expires_at, created_at) "
  146. "VALUES (10, 1, '$h$', 0, 0, :exp, CURRENT_TIMESTAMP)"
  147. ),
  148. {"exp": exp},
  149. )
  150. # Two orphan OTP codes
  151. await conn.execute(
  152. text(
  153. "INSERT INTO user_otp_codes (id, user_id, code_hash, attempts, used, expires_at, created_at) "
  154. "VALUES (11, 999, '$h$', 0, 0, :exp, CURRENT_TIMESTAMP)"
  155. ),
  156. {"exp": exp},
  157. )
  158. await conn.execute(
  159. text(
  160. "INSERT INTO user_otp_codes (id, user_id, code_hash, attempts, used, expires_at, created_at) "
  161. "VALUES (12, 1000, '$h$', 0, 0, :exp, CURRENT_TIMESTAMP)"
  162. ),
  163. {"exp": exp},
  164. )
  165. async with engine_with_full_schema.begin() as conn:
  166. await run_migrations(conn)
  167. async with engine_with_full_schema.begin() as conn:
  168. ids = [row[0] for row in (await conn.execute(text("SELECT id FROM user_otp_codes ORDER BY id"))).all()]
  169. assert ids == [10], f"Expected only the valid OTP row to survive, got {ids}"
  170. async def test_migration_deletes_orphan_long_lived_tokens(engine_with_full_schema):
  171. """Orphan rows in long_lived_tokens must be removed; rows for real users must stay.
  172. Camera-stream tokens whose secret_hash is still valid would otherwise be
  173. matchable by verify() via lookup_prefix even after the owning user is gone
  174. (#1295 review feedback extended #1285).
  175. """
  176. exp = (datetime.now(timezone.utc) + timedelta(days=30)).isoformat()
  177. async with engine_with_full_schema.begin() as conn:
  178. await conn.execute(
  179. text(
  180. "INSERT INTO users (id, username, password_hash, is_active, created_at, updated_at, "
  181. "role, auth_source) VALUES (1, 'survivor', 'h', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, "
  182. "'user', 'local')"
  183. )
  184. )
  185. # Valid token for the real user
  186. await conn.execute(
  187. text(
  188. "INSERT INTO long_lived_tokens (id, user_id, name, lookup_prefix, secret_hash, "
  189. "scope, expires_at, created_at) VALUES (10, 1, 'real', 'aaaa1111', '$2b$h', "
  190. "'camera_stream', :exp, CURRENT_TIMESTAMP)"
  191. ),
  192. {"exp": exp},
  193. )
  194. # Orphan token — user_id=999 does not exist
  195. await conn.execute(
  196. text(
  197. "INSERT INTO long_lived_tokens (id, user_id, name, lookup_prefix, secret_hash, "
  198. "scope, expires_at, created_at) VALUES (11, 999, 'orphan', 'bbbb2222', '$2b$h', "
  199. "'camera_stream', :exp, CURRENT_TIMESTAMP)"
  200. ),
  201. {"exp": exp},
  202. )
  203. async with engine_with_full_schema.begin() as conn:
  204. await run_migrations(conn)
  205. async with engine_with_full_schema.begin() as conn:
  206. ids = [row[0] for row in (await conn.execute(text("SELECT id FROM long_lived_tokens ORDER BY id"))).all()]
  207. assert ids == [10], f"Expected only the valid long-lived token to survive, got {ids}"
  208. # -----------------------------------------------------------------------------
  209. # No-op and idempotency
  210. # -----------------------------------------------------------------------------
  211. async def test_migration_is_noop_on_fresh_install(engine_with_full_schema):
  212. """A fresh DB with empty users + auth tables must not raise and must not
  213. modify anything."""
  214. async with engine_with_full_schema.begin() as conn:
  215. await run_migrations(conn)
  216. await run_migrations(conn) # second run, still fine
  217. # Static queries (one per table) instead of an f-string interpolated loop:
  218. # Bandit B608 flags f"... FROM {tbl}" as a possible SQL-injection vector
  219. # even when ``tbl`` is bound to a tuple of literals. Spelling out each
  220. # table name makes the intent clear and silences the false-positive
  221. # without resorting to a noqa marker. See PR #1295 CodeQL alert #798.
  222. async with engine_with_full_schema.begin() as conn:
  223. oidc_count = (await conn.execute(text("SELECT COUNT(*) FROM user_oidc_links"))).scalar_one()
  224. totp_count = (await conn.execute(text("SELECT COUNT(*) FROM user_totp"))).scalar_one()
  225. otp_count = (await conn.execute(text("SELECT COUNT(*) FROM user_otp_codes"))).scalar_one()
  226. llt_count = (await conn.execute(text("SELECT COUNT(*) FROM long_lived_tokens"))).scalar_one()
  227. assert oidc_count == 0
  228. assert totp_count == 0
  229. assert otp_count == 0
  230. assert llt_count == 0
  231. async def test_migration_is_idempotent(engine_with_full_schema):
  232. """Running the migration twice on data with orphans cleans them once, the
  233. second run finds nothing left and is a no-op."""
  234. async with engine_with_full_schema.begin() as conn:
  235. await conn.execute(
  236. text(
  237. "INSERT INTO users (id, username, password_hash, is_active, created_at, updated_at, "
  238. "role, auth_source) VALUES (1, 'u', 'h', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, "
  239. "'user', 'local')"
  240. )
  241. )
  242. await conn.execute(
  243. text(
  244. "INSERT INTO oidc_providers (id, name, issuer_url, client_id, client_secret, "
  245. "scopes, is_enabled, auto_create_users, auto_link_existing_accounts, email_claim, "
  246. "require_email_verified, created_at, updated_at) VALUES (1, 'p', 'https://x', 'c', "
  247. "'s', 'openid', 1, 1, 0, 'email', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
  248. )
  249. )
  250. await conn.execute(
  251. text(
  252. "INSERT INTO user_oidc_links (id, user_id, provider_id, provider_user_id, created_at) "
  253. "VALUES (1, 999, 1, 'orphan', CURRENT_TIMESTAMP)"
  254. )
  255. )
  256. async with engine_with_full_schema.begin() as conn:
  257. await run_migrations(conn)
  258. # Second run must not crash, must not double-touch anything
  259. async with engine_with_full_schema.begin() as conn:
  260. await run_migrations(conn)
  261. async with engine_with_full_schema.begin() as conn:
  262. count = (await conn.execute(text("SELECT COUNT(*) FROM user_oidc_links"))).scalar_one()
  263. assert count == 0
  264. async def test_migration_keeps_rows_for_existing_users(engine_with_full_schema):
  265. """Belt-and-braces: rows for real users must never be touched even when
  266. other tables have orphans being cleaned at the same time."""
  267. exp = (datetime.now(timezone.utc) + timedelta(minutes=10)).isoformat()
  268. async with engine_with_full_schema.begin() as conn:
  269. for uid in (1, 2):
  270. await conn.execute(
  271. text(
  272. "INSERT INTO users (id, username, password_hash, is_active, created_at, updated_at, "
  273. "role, auth_source) VALUES (:id, :name, 'h', 1, CURRENT_TIMESTAMP, "
  274. "CURRENT_TIMESTAMP, 'user', 'local')"
  275. ),
  276. {"id": uid, "name": f"u{uid}"},
  277. )
  278. await conn.execute(
  279. text(
  280. "INSERT INTO oidc_providers (id, name, issuer_url, client_id, client_secret, "
  281. "scopes, is_enabled, auto_create_users, auto_link_existing_accounts, email_claim, "
  282. "require_email_verified, created_at, updated_at) VALUES (1, 'p', 'https://x', 'c', "
  283. "'s', 'openid', 1, 1, 0, 'email', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
  284. )
  285. )
  286. # Mix: valid + orphan in each table
  287. await conn.execute(
  288. text(
  289. "INSERT INTO user_oidc_links (id, user_id, provider_id, provider_user_id, created_at) "
  290. "VALUES (1, 1, 1, 'real', CURRENT_TIMESTAMP), "
  291. "(2, 999, 1, 'orphan', CURRENT_TIMESTAMP)"
  292. )
  293. )
  294. await conn.execute(
  295. text(
  296. "INSERT INTO user_totp (id, user_id, secret, is_enabled, created_at, updated_at) "
  297. "VALUES (1, 2, 'enc', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), "
  298. "(2, 998, 'orphan', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
  299. )
  300. )
  301. await conn.execute(
  302. text(
  303. "INSERT INTO user_otp_codes (id, user_id, code_hash, attempts, used, expires_at, "
  304. "created_at) VALUES (1, 1, '$h$', 0, 0, :exp, CURRENT_TIMESTAMP), "
  305. "(2, 997, '$h$', 0, 0, :exp, CURRENT_TIMESTAMP)"
  306. ),
  307. {"exp": exp},
  308. )
  309. llt_exp = (datetime.now(timezone.utc) + timedelta(days=30)).isoformat()
  310. await conn.execute(
  311. text(
  312. "INSERT INTO long_lived_tokens (id, user_id, name, lookup_prefix, secret_hash, "
  313. "scope, expires_at, created_at) VALUES (1, 2, 'real', 'aaaa1111', '$h', "
  314. "'camera_stream', :exp, CURRENT_TIMESTAMP), (2, 996, 'orphan', 'bbbb2222', '$h', "
  315. "'camera_stream', :exp, CURRENT_TIMESTAMP)"
  316. ),
  317. {"exp": llt_exp},
  318. )
  319. async with engine_with_full_schema.begin() as conn:
  320. await run_migrations(conn)
  321. async with engine_with_full_schema.begin() as conn:
  322. links = [
  323. row[0] for row in (await conn.execute(text("SELECT user_id FROM user_oidc_links ORDER BY user_id"))).all()
  324. ]
  325. totps = [row[0] for row in (await conn.execute(text("SELECT user_id FROM user_totp ORDER BY user_id"))).all()]
  326. otps = [
  327. row[0] for row in (await conn.execute(text("SELECT user_id FROM user_otp_codes ORDER BY user_id"))).all()
  328. ]
  329. llts = [
  330. row[0] for row in (await conn.execute(text("SELECT user_id FROM long_lived_tokens ORDER BY user_id"))).all()
  331. ]
  332. assert links == [1], f"Expected only user_id=1 to survive in user_oidc_links, got {links}"
  333. assert totps == [2], f"Expected only user_id=2 to survive in user_totp, got {totps}"
  334. assert otps == [1], f"Expected only user_id=1 to survive in user_otp_codes, got {otps}"
  335. assert llts == [2], f"Expected only user_id=2 to survive in long_lived_tokens, got {llts}"