|
|
@@ -0,0 +1,154 @@
|
|
|
+"""Regression test for #794 — LDAP auto-provisioning on legacy SQLite schemas.
|
|
|
+
|
|
|
+Pre-LDAP databases created the `users` table with `password_hash VARCHAR(255) NOT NULL`.
|
|
|
+The LDAP provisioning path inserts users with `password_hash=None`, which crashes on
|
|
|
+upgrade until the migration strips the NOT NULL constraint.
|
|
|
+"""
|
|
|
+
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+import pytest
|
|
|
+from sqlalchemy import text
|
|
|
+from sqlalchemy.exc import IntegrityError
|
|
|
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
|
+
|
|
|
+from backend.app.core.database import run_migrations
|
|
|
+
|
|
|
+
|
|
|
+@pytest.fixture(autouse=True)
|
|
|
+def force_sqlite_dialect(monkeypatch):
|
|
|
+ """The test engine is SQLite but settings.database_url may point to Postgres in dev
|
|
|
+ configs — that would make run_migrations take the Postgres branch and skip the
|
|
|
+ SQLite-specific writable_schema patch we're verifying. Force the sqlite dialect."""
|
|
|
+ from backend.app.core import db_dialect
|
|
|
+
|
|
|
+ monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
|
|
|
+ monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
|
|
|
+ # database.py imported is_sqlite at module load time — patch there too.
|
|
|
+ from backend.app.core import database as database_module
|
|
|
+
|
|
|
+ monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
|
|
|
+
|
|
|
+
|
|
|
+@pytest.fixture
|
|
|
+async def legacy_engine():
|
|
|
+ """Simulate an older install by creating all current tables via create_all, then
|
|
|
+ dropping the `users` table and re-creating it with the legacy NOT NULL schema.
|
|
|
+ This matches the real upgrade path — everything else in the DB looks modern, only
|
|
|
+ the users table carries a stale constraint."""
|
|
|
+ # Import every model so Base.metadata knows about them (same set as conftest).
|
|
|
+ from backend.app.core.database import Base
|
|
|
+ from backend.app.models import ( # noqa: F401
|
|
|
+ ams_history,
|
|
|
+ ams_label,
|
|
|
+ api_key,
|
|
|
+ archive,
|
|
|
+ color_catalog,
|
|
|
+ external_link,
|
|
|
+ filament,
|
|
|
+ group,
|
|
|
+ kprofile_note,
|
|
|
+ maintenance,
|
|
|
+ notification,
|
|
|
+ notification_template,
|
|
|
+ print_queue,
|
|
|
+ printer,
|
|
|
+ project,
|
|
|
+ project_bom,
|
|
|
+ settings,
|
|
|
+ slot_preset,
|
|
|
+ smart_plug,
|
|
|
+ smart_plug_energy_snapshot,
|
|
|
+ spool,
|
|
|
+ spool_assignment,
|
|
|
+ spool_catalog,
|
|
|
+ spool_k_profile,
|
|
|
+ spool_usage_history,
|
|
|
+ spoolbuddy_device,
|
|
|
+ user,
|
|
|
+ user_email_pref,
|
|
|
+ virtual_printer,
|
|
|
+ )
|
|
|
+
|
|
|
+ engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
|
|
|
+ async with engine.begin() as conn:
|
|
|
+ await conn.run_sync(Base.metadata.create_all)
|
|
|
+ # Drop the users table created from the current (nullable) model and replace it
|
|
|
+ # with the pre-LDAP schema that real upgrading installations have on disk.
|
|
|
+ await conn.execute(text("DROP TABLE IF EXISTS user_groups"))
|
|
|
+ await conn.execute(text("DROP TABLE users"))
|
|
|
+ await conn.execute(
|
|
|
+ text("""
|
|
|
+ CREATE TABLE users (
|
|
|
+ id INTEGER PRIMARY KEY,
|
|
|
+ username VARCHAR(100) NOT NULL UNIQUE,
|
|
|
+ password_hash VARCHAR(255) NOT NULL,
|
|
|
+ role VARCHAR(20) NOT NULL DEFAULT 'user',
|
|
|
+ is_active BOOLEAN NOT NULL DEFAULT 1,
|
|
|
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
|
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
|
+ )
|
|
|
+ """)
|
|
|
+ )
|
|
|
+ yield engine
|
|
|
+ await engine.dispose()
|
|
|
+
|
|
|
+
|
|
|
+async def test_legacy_schema_rejects_null_password_before_migration(legacy_engine):
|
|
|
+ """Sanity check: without the migration, inserting a NULL password_hash fails.
|
|
|
+
|
|
|
+ Guards against a false-positive where a future schema change silently allows NULL
|
|
|
+ and the real migration test below becomes meaningless.
|
|
|
+ """
|
|
|
+ async with legacy_engine.begin() as conn:
|
|
|
+ with pytest.raises(IntegrityError):
|
|
|
+ await conn.execute(
|
|
|
+ text(
|
|
|
+ "INSERT INTO users (username, password_hash, role, is_active) "
|
|
|
+ "VALUES ('ldap_alice', NULL, 'user', 1)"
|
|
|
+ )
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+async def test_migration_allows_null_password_hash_for_ldap_users(legacy_engine):
|
|
|
+ """After running migrations on a legacy DB, LDAP users (password_hash=NULL) insert
|
|
|
+ successfully — reproduces and verifies the #794 bug reported by DylanBrass."""
|
|
|
+ async with legacy_engine.begin() as conn:
|
|
|
+ await run_migrations(conn)
|
|
|
+
|
|
|
+ session_maker = async_sessionmaker(legacy_engine, class_=AsyncSession, expire_on_commit=False)
|
|
|
+ async with session_maker() as session:
|
|
|
+ await session.execute(
|
|
|
+ text(
|
|
|
+ "INSERT INTO users (username, email, password_hash, role, auth_source, is_active) "
|
|
|
+ "VALUES (:u, :e, NULL, 'user', 'ldap', 1)"
|
|
|
+ ),
|
|
|
+ {"u": "ldap_bob", "e": "bob@example.com"},
|
|
|
+ )
|
|
|
+ await session.commit()
|
|
|
+
|
|
|
+ result = await session.execute(
|
|
|
+ text("SELECT username, password_hash, auth_source FROM users WHERE username = 'ldap_bob'")
|
|
|
+ )
|
|
|
+ row = result.one()
|
|
|
+ assert row.username == "ldap_bob"
|
|
|
+ assert row.password_hash is None
|
|
|
+ assert row.auth_source == "ldap"
|
|
|
+
|
|
|
+
|
|
|
+async def test_migration_is_idempotent(legacy_engine):
|
|
|
+ """Running migrations twice must not break the writable_schema patch."""
|
|
|
+ async with legacy_engine.begin() as conn:
|
|
|
+ await run_migrations(conn)
|
|
|
+ async with legacy_engine.begin() as conn:
|
|
|
+ await run_migrations(conn)
|
|
|
+
|
|
|
+ session_maker = async_sessionmaker(legacy_engine, class_=AsyncSession, expire_on_commit=False)
|
|
|
+ async with session_maker() as session:
|
|
|
+ await session.execute(
|
|
|
+ text(
|
|
|
+ "INSERT INTO users (username, password_hash, role, auth_source, is_active) "
|
|
|
+ "VALUES ('ldap_carol', NULL, 'user', 'ldap', 1)"
|
|
|
+ )
|
|
|
+ )
|
|
|
+ await session.commit()
|