Files
bambuddy/backend/tests/unit/test_ldap_migration.py
T
maziggy 3d893b22f3 fix(auth): make password_hash nullable on upgraded SQLite installs (#794)
LDAP auto-provisioning hit a NOT NULL constraint error on upgraded SQLite
  installs because the existing migration only ran on PostgreSQL. The SQLite
  branch now patches sqlite_master via writable_schema and bumps schema_version
  so the change takes effect without a restart. Fresh installs were unaffected.
2026-04-11 11:33:49 +02:00

155 lines
5.8 KiB
Python

"""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()