test_ldap_migration.py 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
  1. """Regression test for #794 — LDAP auto-provisioning on legacy SQLite schemas.
  2. Pre-LDAP databases created the `users` table with `password_hash VARCHAR(255) NOT NULL`.
  3. The LDAP provisioning path inserts users with `password_hash=None`, which crashes on
  4. upgrade until the migration strips the NOT NULL constraint.
  5. """
  6. from __future__ import annotations
  7. import pytest
  8. from sqlalchemy import text
  9. from sqlalchemy.exc import IntegrityError
  10. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
  11. from backend.app.core.database import run_migrations
  12. @pytest.fixture(autouse=True)
  13. def force_sqlite_dialect(monkeypatch):
  14. """The test engine is SQLite but settings.database_url may point to Postgres in dev
  15. configs — that would make run_migrations take the Postgres branch and skip the
  16. SQLite-specific writable_schema patch we're verifying. Force the sqlite dialect."""
  17. from backend.app.core import db_dialect
  18. monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
  19. monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
  20. # database.py imported is_sqlite at module load time — patch there too.
  21. from backend.app.core import database as database_module
  22. monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
  23. @pytest.fixture
  24. async def legacy_engine():
  25. """Simulate an older install by creating all current tables via create_all, then
  26. dropping the `users` table and re-creating it with the legacy NOT NULL schema.
  27. This matches the real upgrade path — everything else in the DB looks modern, only
  28. the users table carries a stale constraint."""
  29. # Import every model so Base.metadata knows about them (same set as conftest).
  30. from backend.app.core.database import Base
  31. from backend.app.models import ( # noqa: F401
  32. ams_history,
  33. ams_label,
  34. api_key,
  35. archive,
  36. color_catalog,
  37. external_link,
  38. filament,
  39. group,
  40. kprofile_note,
  41. maintenance,
  42. notification,
  43. notification_template,
  44. print_queue,
  45. printer,
  46. project,
  47. project_bom,
  48. settings,
  49. slot_preset,
  50. smart_plug,
  51. smart_plug_energy_snapshot,
  52. spool,
  53. spool_assignment,
  54. spool_catalog,
  55. spool_k_profile,
  56. spool_usage_history,
  57. spoolbuddy_device,
  58. user,
  59. user_email_pref,
  60. virtual_printer,
  61. )
  62. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  63. async with engine.begin() as conn:
  64. await conn.run_sync(Base.metadata.create_all)
  65. # Drop the users table created from the current (nullable) model and replace it
  66. # with the pre-LDAP schema that real upgrading installations have on disk.
  67. await conn.execute(text("DROP TABLE IF EXISTS user_groups"))
  68. await conn.execute(text("DROP TABLE users"))
  69. await conn.execute(
  70. text("""
  71. CREATE TABLE users (
  72. id INTEGER PRIMARY KEY,
  73. username VARCHAR(100) NOT NULL UNIQUE,
  74. password_hash VARCHAR(255) NOT NULL,
  75. role VARCHAR(20) NOT NULL DEFAULT 'user',
  76. is_active BOOLEAN NOT NULL DEFAULT 1,
  77. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  78. updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
  79. )
  80. """)
  81. )
  82. yield engine
  83. await engine.dispose()
  84. async def test_legacy_schema_rejects_null_password_before_migration(legacy_engine):
  85. """Sanity check: without the migration, inserting a NULL password_hash fails.
  86. Guards against a false-positive where a future schema change silently allows NULL
  87. and the real migration test below becomes meaningless.
  88. """
  89. async with legacy_engine.begin() as conn:
  90. with pytest.raises(IntegrityError):
  91. await conn.execute(
  92. text(
  93. "INSERT INTO users (username, password_hash, role, is_active) "
  94. "VALUES ('ldap_alice', NULL, 'user', 1)"
  95. )
  96. )
  97. async def test_migration_allows_null_password_hash_for_ldap_users(legacy_engine):
  98. """After running migrations on a legacy DB, LDAP users (password_hash=NULL) insert
  99. successfully — reproduces and verifies the #794 bug reported by DylanBrass."""
  100. async with legacy_engine.begin() as conn:
  101. await run_migrations(conn)
  102. session_maker = async_sessionmaker(legacy_engine, class_=AsyncSession, expire_on_commit=False)
  103. async with session_maker() as session:
  104. await session.execute(
  105. text(
  106. "INSERT INTO users (username, email, password_hash, role, auth_source, is_active) "
  107. "VALUES (:u, :e, NULL, 'user', 'ldap', 1)"
  108. ),
  109. {"u": "ldap_bob", "e": "bob@example.com"},
  110. )
  111. await session.commit()
  112. result = await session.execute(
  113. text("SELECT username, password_hash, auth_source FROM users WHERE username = 'ldap_bob'")
  114. )
  115. row = result.one()
  116. assert row.username == "ldap_bob"
  117. assert row.password_hash is None
  118. assert row.auth_source == "ldap"
  119. async def test_migration_is_idempotent(legacy_engine):
  120. """Running migrations twice must not break the writable_schema patch."""
  121. async with legacy_engine.begin() as conn:
  122. await run_migrations(conn)
  123. async with legacy_engine.begin() as conn:
  124. await run_migrations(conn)
  125. session_maker = async_sessionmaker(legacy_engine, class_=AsyncSession, expire_on_commit=False)
  126. async with session_maker() as session:
  127. await session.execute(
  128. text(
  129. "INSERT INTO users (username, password_hash, role, auth_source, is_active) "
  130. "VALUES ('ldap_carol', NULL, 'user', 'ldap', 1)"
  131. )
  132. )
  133. await session.commit()